diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 000000000..8a5eba44e
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,9 @@
+.tox/
+.venv/
+venv/
+dist/
+build/
+*.egg-info
+.coverage
+.github/
+coverage.xml
diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
new file mode 100644
index 000000000..e432c2e9d
--- /dev/null
+++ b/.git-blame-ignore-revs
@@ -0,0 +1,4 @@
+# Require keyword arguments for register_custom_action
+d74545a309ed02fdc8d32157f8ccb9f7559cd185
+# chore: reformat code with `skip_magic_trailing_comma = true`
+a54c422f96637dd13b45db9b55aa332af18e0429
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 000000000..c6db5eabc
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: false
+contact_links:
+  - name: Python Gitlab Community Support
+    url: https://github.com/python-gitlab/python-gitlab/discussions
+    about: Please ask and answer questions here.
+  - name: 💬 Gitter Chat
+    url: https://gitter.im/python-gitlab/Lobby
+    about: Chat with devs and community
diff --git a/.github/ISSUE_TEMPLATE/issue_template.md b/.github/ISSUE_TEMPLATE/issue_template.md
new file mode 100644
index 000000000..552158fc5
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/issue_template.md
@@ -0,0 +1,22 @@
+---
+name: Issue report
+about: Create a report to help us improve
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+## Description of the problem, including code/CLI snippet
+
+
+## Expected Behavior
+
+
+## Actual Behavior
+
+
+## Specifications
+
+  - python-gitlab version:
+  - Gitlab server version (or gitlab.com):
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 000000000..465fcb358
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,23 @@
+<!-- Please make sure your commit messages follow Conventional Commits
+(https://www.conventionalcommits.org) with a commit type (e.g. feat, fix, refactor, chore):
+
+Bad:        Added support for release links
+Good:     feat(api): add support for release links
+
+Bad:        Update documentation for projects
+Good:     docs(projects): update example for saving project attributes-->
+
+## Changes
+
+<!-- Remove this comment and describe your changes here. -->
+
+### Documentation and testing
+
+Please consider whether this PR needs documentation and tests. **This is not required**, but highly appreciated:
+
+- [ ] Documentation in the matching [docs section](https://github.com/python-gitlab/python-gitlab/tree/main/docs)
+- [ ] [Unit tests](https://github.com/python-gitlab/python-gitlab/tree/main/tests/unit) and/or [functional tests](https://github.com/python-gitlab/python-gitlab/tree/main/tests/functional)
+
+<!--
+Note: In some cases, basic functional tests may be easier to add, as they do not require adding mocked GitLab responses.
+-->
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 000000000..c974f3a45
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,50 @@
+name: Docs
+
+# If a pull-request is pushed then cancel all previously running jobs related
+# to that pull-request
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
+  cancel-in-progress: true 
+
+on:
+  push:
+    branches:
+      - main
+  pull_request:
+    branches:
+      - main
+      - master
+
+env:
+  PY_COLORS: 1
+
+jobs:
+  sphinx:
+    runs-on: ubuntu-24.04
+    steps:
+      - uses: actions/checkout@v4.2.2
+      - name: Set up Python
+        uses: actions/setup-python@v5.6.0
+        with:
+          python-version: "3.13"
+      - name: Install dependencies
+        run: pip install tox
+      - name: Build docs
+        env:
+          TOXENV: docs
+        run: tox
+
+  twine-check:
+    runs-on: ubuntu-24.04
+    steps:
+      - uses: actions/checkout@v4.2.2
+      - name: Set up Python
+        uses: actions/setup-python@v5.6.0
+        with:
+          python-version: "3.13"
+      - name: Install dependencies
+        run: pip install tox twine wheel
+      - name: Check twine readme rendering
+        env:
+          TOXENV: twine-check
+        run: tox
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 000000000..d16f7fe09
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,43 @@
+name: Lint
+
+# If a pull-request is pushed then cancel all previously running jobs related
+# to that pull-request
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
+  cancel-in-progress: true 
+
+on:
+  push:
+    branches:
+      - main
+  pull_request:
+    branches:
+      - main
+      - master
+
+env:
+  PY_COLORS: 1
+
+jobs:
+  lint:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4.2.2
+        with:
+          fetch-depth: 0
+      - uses: actions/setup-python@v5.6.0
+        with:
+          python-version: "3.13"
+      - run: pip install --upgrade tox
+      - name: Run commitizen (https://commitizen-tools.github.io/commitizen/)
+        run: tox -e cz
+      - name: Run black code formatter (https://black.readthedocs.io/en/stable/)
+        run: tox -e black -- --check
+      - name: Run flake8 (https://flake8.pycqa.org/en/latest/)
+        run: tox -e flake8
+      - name: Run mypy static typing checker (http://mypy-lang.org/)
+        run: tox -e mypy
+      - name: Run isort import order checker (https://pycqa.github.io/isort/)
+        run: tox -e isort -- --check
+      - name: Run pylint Python code static checker (https://github.com/PyCQA/pylint)
+        run: tox -e pylint
diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml
new file mode 100644
index 000000000..05e21065c
--- /dev/null
+++ b/.github/workflows/lock.yml
@@ -0,0 +1,20 @@
+name: 'Lock Closed Issues'
+
+on:
+  schedule:
+    - cron: '0 0 * * 1 '
+  workflow_dispatch: # For manual cleanup
+
+permissions:
+  issues: write
+
+concurrency:
+  group: lock
+
+jobs:
+  action:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: dessant/lock-threads@v5.0.1
+        with:
+          process-only: 'issues'
diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml
new file mode 100644
index 000000000..9fadeca81
--- /dev/null
+++ b/.github/workflows/pre_commit.yml
@@ -0,0 +1,39 @@
+name: pre_commit
+
+# If a pull-request is pushed then cancel all previously running jobs related
+# to that pull-request
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
+  cancel-in-progress: true 
+
+on:
+  push:
+    branches:
+      - main
+    paths:
+      .github/workflows/pre_commit.yml
+      .pre-commit-config.yaml
+  pull_request:
+    branches:
+      - main
+      - master
+    paths:
+      - .github/workflows/pre_commit.yml
+      - .pre-commit-config.yaml
+
+env:
+  PY_COLORS: 1
+
+jobs:
+
+  pre_commit:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4.2.2
+      - uses: actions/setup-python@v5.6.0
+        with:
+          python-version: "3.13"
+      - name: install tox
+        run: pip install tox==3.26.0
+      - name: pre-commit
+        run: tox -e pre-commit
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 000000000..576c1befb
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,38 @@
+name: Release
+
+on:
+  schedule:
+    - cron: '0 0 28 * *'  # Monthly auto-release
+  workflow_dispatch:      # Manual trigger for quick fixes
+
+jobs:
+  release:
+    if: github.repository == 'python-gitlab/python-gitlab'
+    runs-on: ubuntu-latest
+    concurrency: release
+    permissions:
+      id-token: write
+    environment: pypi.org
+    steps:
+    - uses: actions/checkout@v4.2.2
+      with:
+        fetch-depth: 0
+        token: ${{ secrets.RELEASE_GITHUB_TOKEN }}
+
+    - name: Python Semantic Release
+      id: release
+      uses: python-semantic-release/python-semantic-release@v10.2.0
+      with:
+        github_token: ${{ secrets.RELEASE_GITHUB_TOKEN }}
+
+    - name: Publish package distributions to PyPI
+      uses: pypa/gh-action-pypi-publish@release/v1
+      # NOTE: DO NOT wrap the conditional in ${{ }} as it will always evaluate to true.
+      # See https://github.com/actions/runner/issues/1173
+      if: steps.release.outputs.released == 'true'
+
+    - name: Publish package distributions to GitHub Releases
+      uses: python-semantic-release/publish-action@v10.2.0
+      if: steps.release.outputs.released == 'true'
+      with:
+        github_token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/rerun-test.yml b/.github/workflows/rerun-test.yml
new file mode 100644
index 000000000..5d477b09f
--- /dev/null
+++ b/.github/workflows/rerun-test.yml
@@ -0,0 +1,16 @@
+name: 'Rerun failed workflows'
+
+on:
+  issue_comment:
+    types: [created]
+
+jobs:
+  rerun_pr_tests:
+    name: rerun_pr_tests
+    if: ${{ github.event.issue.pull_request }}
+    runs-on: ubuntu-24.04
+    steps:
+    - uses: estroz/rerun-actions@main
+      with:
+        repo_token: ${{ secrets.GITHUB_TOKEN }}
+        comment_id: ${{ github.event.comment.id }}
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
new file mode 100644
index 000000000..0b6cbe5db
--- /dev/null
+++ b/.github/workflows/stale.yml
@@ -0,0 +1,87 @@
+# https://github.com/actions/stale
+name: 'Close stale issues and PRs'
+on:
+  schedule:
+    - cron: '30 1 * * *'
+
+permissions:
+  issues: write
+  pull-requests: write
+
+concurrency:
+  group: lock
+
+jobs:
+  stale:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/stale@v9.1.0
+        with:
+          operations-per-run: 500
+          stale-issue-label: "stale"
+          stale-pr-label: "stale"
+
+          # If an issue/PR has an assignee it won't be marked as stale
+          exempt-all-assignees: true
+          stale-issue-message: |
+            This issue was marked stale because it has been open 60 days with
+            no activity. Please remove the stale label or comment on this
+            issue. Otherwise, it will be closed in 15 days.
+
+            As an open-source project, we rely on community contributions to
+            address many of the reported issues. Without a proposed fix or
+            active work towards a solution it is our policy to close inactive
+            issues. This is documented in CONTRIBUTING.rst
+
+            **How to keep this issue open:**
+            * If you are still experiencing this issue and are willing to
+            investigate a fix, please comment and let us know.
+            * If you (or someone else) can propose a pull request with a
+            solution, that would be fantastic.
+            * Any significant update or active discussion indicating progress
+            will also prevent closure.
+
+            We value your input. If you can help provide a fix, we'd be happy
+            to keep this issue open and support your efforts.
+
+            This is documented in CONTRIBUTING.rst
+            https://github.com/python-gitlab/python-gitlab/blob/main/CONTRIBUTING.rst
+
+          days-before-issue-stale: 60
+          days-before-issue-close: 15
+          close-issue-message: |
+            This issue was closed because it has been marked stale for 15 days
+            with no activity.
+
+            This open-source project relies on community contributions, and
+            while we value all feedback, we have a limited capacity to address
+            every issue without a clear path forward.
+
+            Currently, this issue hasn't received a proposed fix, and there
+            hasn't been recent active discussion indicating someone is planning
+            to work on it. To maintain a manageable backlog and focus our
+            efforts, we will be closing this issue for now.
+
+            **This doesn't mean the issue isn't valid or important.** If you or
+            anyone else in the community is willing to investigate and propose
+            a solution (e.g., by submitting a pull request), please do.
+
+            We believe that those who feel a bug is important enough to fix
+            should ideally be part of the solution. Your contributions are
+            highly welcome.
+
+            Thank you for your understanding and potential future
+            contributions.
+
+            This is documented in CONTRIBUTING.rst
+            https://github.com/python-gitlab/python-gitlab/blob/main/CONTRIBUTING.rst
+
+          stale-pr-message: >
+            This Pull Request (PR) was marked stale because it has been open 90 days
+            with no activity.  Please remove the stale label or comment on this PR.
+            Otherwise, it will be closed in 15 days.
+          days-before-pr-stale: 90
+          days-before-pr-close: 15
+          close-pr-message: >
+            This PR was closed because it has been marked stale for 15 days with no
+            activity. If this PR is still valid, please re-open.
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 000000000..17d514b11
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,146 @@
+name: Test
+
+# If a pull-request is pushed then cancel all previously running jobs related
+# to that pull-request
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
+  cancel-in-progress: true 
+
+on:
+  push:
+    branches:
+      - main
+  pull_request:
+    branches:
+      - main
+      - master
+
+env:
+  PY_COLORS: 1
+
+jobs:
+  unit:
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        os: [ubuntu-latest]
+        python:
+          - version: "3.9"
+            toxenv: py39,smoke
+          - version: "3.10"
+            toxenv: py310,smoke
+          - version: "3.11"
+            toxenv: py311,smoke
+          - version: "3.12"
+            toxenv: py312,smoke
+          - version: "3.13"
+            toxenv: py313,smoke
+          - version: "3.14.0-alpha - 3.14" # SemVer's version range syntax
+            toxenv: py314,smoke
+        include:
+          - os: macos-latest
+            python:
+              version: "3.13"
+              toxenv: py313,smoke
+          - os: windows-latest
+            python:
+              version: "3.13"
+              toxenv: py313,smoke
+    steps:
+      - uses: actions/checkout@v4.2.2
+      - name: Set up Python ${{ matrix.python.version }}
+        uses: actions/setup-python@v5.6.0
+        with:
+          python-version: ${{ matrix.python.version }}
+      - name: Install dependencies
+        run: pip install tox
+      - name: Run tests
+        env:
+          TOXENV: ${{ matrix.python.toxenv }}
+        run: tox --skip-missing-interpreters false
+
+  functional:
+    timeout-minutes: 30
+    runs-on: ubuntu-24.04
+    strategy:
+      matrix:
+        toxenv: [api_func_v4, cli_func_v4]
+    steps:
+      - uses: actions/checkout@v4.2.2
+      - name: Set up Python
+        uses: actions/setup-python@v5.6.0
+        with:
+          python-version: "3.13"
+      - name: Install dependencies
+        run: pip install tox
+      - name: Run tests
+        env:
+          TOXENV: ${{ matrix.toxenv }}
+        run: tox -- --override-ini='log_cli=True'
+      - name: Upload codecov coverage
+        uses: codecov/codecov-action@v5.4.3
+        with:
+          files: ./coverage.xml
+          flags: ${{ matrix.toxenv }}
+          fail_ci_if_error: false
+          token: ${{ secrets.CODECOV_TOKEN }}
+
+  coverage:
+    runs-on: ubuntu-24.04
+    steps:
+      - uses: actions/checkout@v4.2.2
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v5.6.0
+        with:
+          python-version: "3.13"
+      - name: Install dependencies
+        run: pip install tox
+      - name: Run tests
+        env:
+          PY_COLORS: 1
+          TOXENV: cover
+        run: tox
+      - name: Upload codecov coverage
+        uses: codecov/codecov-action@v5.4.3
+        with:
+          files: ./coverage.xml
+          flags: unit
+          fail_ci_if_error: false
+          token: ${{ secrets.CODECOV_TOKEN }}
+
+  dist:
+    runs-on: ubuntu-latest
+    name: Python wheel
+    steps:
+    - uses: actions/checkout@v4.2.2
+    - uses: actions/setup-python@v5.6.0
+      with:
+        python-version: "3.13"
+    - name: Install dependencies
+      run: |
+        pip install -r requirements-test.txt
+    - name: Build package
+      run: python -m build -o dist/
+    - uses: actions/upload-artifact@v4.6.2
+      with:
+        name: dist
+        path: dist
+
+  test:
+    runs-on: ubuntu-latest
+    needs: [dist]
+    steps:
+    - uses: actions/checkout@v4.2.2
+    - name: Set up Python
+      uses: actions/setup-python@v5.6.0
+      with:
+        python-version: '3.13'
+    - uses: actions/download-artifact@v4.3.0
+      with:
+        name: dist
+        path: dist
+    - name: install dist/*.whl and requirements
+      run: pip install dist/*.whl -r requirements-test.txt tox
+    - name: Run tests
+      run: tox -e install
diff --git a/.gitignore b/.gitignore
index daef3f311..849ca6e85 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,26 @@
 *.pyc
 build/
 dist/
+htmlcov/
 MANIFEST
 .*.swp
 *.egg-info
 .idea/
+coverage.xml
 docs/_build
-.testrepository/
+.coverage*
+.python-version
 .tox
+.venv/
+venv/
+
+# Include tracked hidden files and directories in search and diff tools
+!.dockerignore
+!.env
+!.github/
+!.gitignore
+!.gitlab-ci.yml
+!.mypy.ini
+!.pre-commit-config.yaml
+!.readthedocs.yml
+!.renovaterc.json
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 000000000..b1094aa9a
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,57 @@
+image: python:3.13
+
+stages:
+  - build
+  - deploy
+  - promote
+
+build-images:
+  stage: build
+  image:
+    name: gcr.io/kaniko-project/executor:debug
+    entrypoint: [""]
+  script:
+    - executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination $CI_REGISTRY_IMAGE/$OS_ARCH:$CI_COMMIT_TAG-alpine
+    - executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination $CI_REGISTRY_IMAGE/$OS_ARCH:$CI_COMMIT_TAG-slim-bullseye --build-arg PYTHON_FLAVOR=slim-bullseye
+  rules:
+    - if: $CI_COMMIT_TAG
+  tags:
+    - $RUNNER_TAG
+  parallel:
+    matrix:
+      # See tags in https://docs.gitlab.com/ee/ci/runners/hosted_runners/linux.html
+      - RUNNER_TAG: saas-linux-medium-amd64
+        OS_ARCH: linux/amd64
+      - RUNNER_TAG: saas-linux-medium-arm64
+        OS_ARCH: linux/arm64
+
+deploy-images:
+  stage: deploy
+  image: 
+    name: mplatform/manifest-tool:alpine-v2.0.4@sha256:38b399ff66f9df247af59facceb7b60e2cd01c2d649aae318da7587efb4bbf87
+    entrypoint: [""]
+  script:
+    - manifest-tool --username $CI_REGISTRY_USER --password $CI_REGISTRY_PASSWORD push from-args
+      --platforms linux/amd64,linux/arm64
+      --template $CI_REGISTRY_IMAGE/OS/ARCH:$CI_COMMIT_TAG-alpine
+      --target $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG-alpine
+    - manifest-tool --username $CI_REGISTRY_USER --password $CI_REGISTRY_PASSWORD push from-args
+      --platforms linux/amd64,linux/arm64
+      --template $CI_REGISTRY_IMAGE/OS/ARCH:$CI_COMMIT_TAG-slim-bullseye
+      --target $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG-slim-bullseye
+  rules:
+    - if: $CI_COMMIT_TAG
+
+tag-latest:
+  stage: promote
+  image:
+    name: gcr.io/go-containerregistry/crane:debug
+    entrypoint: [""]
+  script:
+    - crane auth login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
+    - crane tag $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG-alpine ${CI_COMMIT_TAG}       # /python-gitlab:v1.2.3
+    - crane tag $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG-alpine latest                 # /python-gitlab:latest
+    - crane tag $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG-alpine alpine                 # /python-gitlab:alpine
+    - crane tag $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG-slim-bullseye slim-bullseye   # /python-gitlab:slim-bullseye
+  rules:
+    - if: $CI_COMMIT_TAG
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 000000000..f18249f20
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,56 @@
+default_language_version:
+    python: python3
+
+repos:
+  - repo: https://github.com/psf/black
+    rev: 25.1.0
+    hooks:
+      - id: black
+  - repo: https://github.com/commitizen-tools/commitizen
+    rev: v4.8.3
+    hooks:
+      - id: commitizen
+        stages: [commit-msg]
+  - repo: https://github.com/pycqa/flake8
+    rev: 7.3.0
+    hooks:
+      - id: flake8
+  - repo: https://github.com/pycqa/isort
+    rev: 6.0.1
+    hooks:
+      - id: isort
+  - repo: https://github.com/pycqa/pylint
+    rev: v3.3.7
+    hooks:
+      - id: pylint
+        additional_dependencies:
+          - argcomplete==2.0.0
+          - gql==3.5.0
+          - httpx==0.27.2
+          - pytest==7.4.2
+          - requests==2.28.1
+          - requests-toolbelt==1.0.0
+        files: 'gitlab/'
+  - repo: https://github.com/pre-commit/mirrors-mypy
+    rev: v1.17.0
+    hooks:
+      - id: mypy
+        args: []
+        additional_dependencies:
+          - gql==3.5.0
+          - httpx==0.27.2
+          - jinja2==3.1.2
+          - pytest==7.4.2
+          - responses==0.23.3
+          - types-PyYAML==6.0.12
+          - types-requests==2.28.11.2
+  - repo: https://github.com/pre-commit/pygrep-hooks
+    rev: v1.10.0
+    hooks:
+      - id: rst-backticks
+      - id: rst-directive-colons
+      - id: rst-inline-touching-normal
+  - repo: https://github.com/maxbrunet/pre-commit-renovate
+    rev: 41.42.1
+    hooks:
+      - id: renovate-config-validator
diff --git a/.readthedocs.yml b/.readthedocs.yml
new file mode 100644
index 000000000..2d561b88b
--- /dev/null
+++ b/.readthedocs.yml
@@ -0,0 +1,17 @@
+version: 2
+
+build:
+  os: ubuntu-22.04
+  tools:
+    python: "3.11"
+
+sphinx:
+  configuration: docs/conf.py
+
+formats:
+  - pdf
+  - epub
+
+python:
+  install:
+    - requirements: requirements-docs.txt
diff --git a/.renovaterc.json b/.renovaterc.json
new file mode 100644
index 000000000..29fffb8f5
--- /dev/null
+++ b/.renovaterc.json
@@ -0,0 +1,68 @@
+{
+  "extends": [
+    "config:base",
+    ":enablePreCommit",
+    "group:allNonMajor",
+    "schedule:weekly"
+  ],
+  "ignorePaths": [
+    "**/.venv/**",
+    "**/node_modules/**"
+  ],
+  "pip_requirements": {
+    "fileMatch": ["^requirements(-[\\w]*)?\\.txt$"]
+  },
+  "regexManagers": [
+    {
+      "fileMatch": [
+        "(^|/)tests\\/functional\\/fixtures\\/\\.env$"
+      ],
+      "matchStrings": [
+        "GITLAB_TAG=(?<currentValue>.*?)\n"
+      ],
+      "depNameTemplate": "gitlab/gitlab-ee",
+      "datasourceTemplate": "docker",
+      "versioningTemplate": "loose"
+    },
+    {
+      "fileMatch": [
+        "(^|/)tests\\/functional\\/fixtures\\/\\.env$"
+      ],
+      "matchStrings": [
+        "GITLAB_RUNNER_TAG=(?<currentValue>.*?)\n"
+      ],
+      "depNameTemplate": "gitlab/gitlab-runner",
+      "datasourceTemplate": "docker",
+      "versioningTemplate": "loose"
+    }
+  ],
+  "packageRules": [
+    {
+      "depTypeList": [
+        "action"
+      ],
+      "extractVersion": "^(?<version>v\\d+\\.\\d+\\.\\d+)$",
+      "versioning": "regex:^v(?<major>\\d+)(\\.(?<minor>\\d+)\\.(?<patch>\\d+))?$"
+    },
+    {
+      "packageName": "argcomplete",
+      "enabled": false
+    },
+    {
+      "packagePatterns": [
+        "^gitlab\/gitlab-.+$"
+      ],
+      "automerge": true,
+      "groupName": "GitLab"
+    },
+    {
+      "matchPackageNames": [
+        "pre-commit/mirrors-mypy"
+      ],
+      "matchManagers": [
+        "pre-commit"
+      ],
+      "versioning": "pep440"
+    }
+  ]
+}
diff --git a/.testr.conf b/.testr.conf
deleted file mode 100644
index 44644a639..000000000
--- a/.testr.conf
+++ /dev/null
@@ -1,4 +0,0 @@
-[DEFAULT]
-test_command=${PYTHON:-python} -m subunit.run discover -t ./ ./gitlab/tests $LISTOPT $IDOPTION
-test_id_option=--load-list $IDFILE
-test_list_option=--list
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index dd405f523..000000000
--- a/.travis.yml
+++ /dev/null
@@ -1,23 +0,0 @@
-sudo: required
-services:
-  - docker
-addons:
-  apt:
-    sources:
-      - deadsnakes
-    packages:
-      - python3.5
-language: python
-python: 2.7
-env:
-  - TOX_ENV=py35
-  - TOX_ENV=py34
-  - TOX_ENV=py27
-  - TOX_ENV=pep8
-  - TOX_ENV=docs
-  - TOX_ENV=py_func
-  - TOX_ENV=cli_func
-install:
-  - pip install tox
-script:
-  - tox -e $TOX_ENV
diff --git a/AUTHORS b/AUTHORS
index 3e38faff0..4f131c2a8 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -1,53 +1,22 @@
-Authors
--------
+Authors / Maintainers
+---------------------
 
-Gauvain Pocentek <gauvain@pocentek.net>
-Mika Mäenpää <mika.j.maenpaa@tut.fi>
+Original creator, no longer active
+==================================
+Gauvain Pocentek <gauvainpocentek@gmail.com>
+
+Current Maintainers
+===================
+John L. Villalovos <john@sodarock.com>
+Max Wittig <max.wittig@siemens.com>
+Nejc Habjan <nejc.habjan@siemens.com>
+Roger Meier <r.meier@siemens.com>
 
 Contributors
 ------------
 
-Adam Reid <areid@navtech.aero>
-Amar Sood (tekacs) <mail@tekacs.com>
-Andrew Austin <aaustin@terremark.com>
-Armin Weihbold <armin.weihbold@gmail.com>
-Asher256 <Asher256@users.noreply.github.com>
-Asher256@users.noreply.github.com <Asher256>
-Christian <cgumpert@users.noreply.github.com>
-Christian Wenk <christian.wenk@omicronenergy.com>
-Colin D Bennett <colin.bennett@harman.com>
-Crestez Dan Leonard <lcrestez@ixiacom.com>
-Daniel Kimsey <dekimsey@ufl.edu>
-derek-austin <derek.austin35@mailinator.com>
-Diego Giovane Pasqualin <dpasqualin@c3sl.ufpr.br>
-Erik Weatherwax <erik.weatherwax@xls.xerox.com>
-fgouteroux <francois.gouteroux@d2-si.eu>
-Greg Allen <GregoryEAllen@users.noreply.github.com>
-Guyzmo <guyzmo+github@m0g.net>
-hakkeroid <hakkeroid@users.noreply.github.com>
-itxaka <itxakaserrano@gmail.com>
-Ivica Arsov <ivica.arsov@sculpteo.com>
-James (d0c_s4vage) Johnson <james.johnson@exodusintel.com>
-Jason Antman <jason@jasonantman.com>
-Jonathon Reinhart <Jonathon.Reinhart@gmail.com>
-Koen Smets <koen.smets@gmail.com>
-Kris Gambirazzi <Kris@sitehost.co.nz>
-Mart Sõmermaa <mart.somermaa@cgi.com>
-massimone88 <stefano.mandruzzato@gmail.com>
-Matt Odden <locke105@gmail.com>
-Michal Galet <michal.galet@gmail.com>
-Mikhail Lopotkov <ms.lopotkov@tensor.ru>
-Missionrulz <missionrulz@gmail.com>
-pa4373 <pa4373@gmail.com>
-Patrick Miller <patrick@velocitywebworks.com>
-Peng Xiao <xiaoquwl@gmail.com>
-Pete Browne <pete.browne@localmed.com>
-Peter Mosmans <support@go-forward.net>
-Philipp Busch <philipp.busch@momox.biz>
-Rafael Eyng <rafaeleyng@gmail.com>
-Richard Hansen <rhansen@rhansen.org>
-samcday <sam.c.day@gmail.com>
-Stefan K. Dunkler <stefan.dun@gmail.com>
-Stefan Klug <klug.stefan@gmx.de>
-Stefano Mandruzzato <stefano.mandruzzato@gmail.com>
-Will Starms <vilhelmen@gmail.com>
+Significant contributor, 2014
+=============================
+Mika Mäenpää <mika.j.maenpaa@tut.fi>
+
+See ``git log`` for a full list of contributors.
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 000000000..c4cf99cd4
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,8640 @@
+# CHANGELOG
+
+
+## v5.6.0 (2025-01-28)
+
+### Features
+
+- **group**: Add support for group level MR approval rules
+  ([`304bdd0`](https://github.com/python-gitlab/python-gitlab/commit/304bdd09cd5e6526576c5ec58cb3acd7e1a783cb))
+
+
+## v5.5.0 (2025-01-28)
+
+### Chores
+
+- Add deprecation warning for mirror_pull functions
+  ([`7f6fd5c`](https://github.com/python-gitlab/python-gitlab/commit/7f6fd5c3aac5e2f18adf212adbce0ac04c7150e1))
+
+- Relax typing constraints for response action
+  ([`f430078`](https://github.com/python-gitlab/python-gitlab/commit/f4300782485ee6c38578fa3481061bd621656b0e))
+
+- **tests**: Catch deprecation warnings
+  ([`0c1af08`](https://github.com/python-gitlab/python-gitlab/commit/0c1af08bc73611d288f1f67248cff9c32c685808))
+
+### Documentation
+
+- Add usage of pull mirror
+  ([`9b374b2`](https://github.com/python-gitlab/python-gitlab/commit/9b374b2c051f71b8ef10e22209b8e90730af9d9b))
+
+- Remove old pull mirror implementation
+  ([`9e18672`](https://github.com/python-gitlab/python-gitlab/commit/9e186726c8a5ae70ca49c56b2be09b34dbf5b642))
+
+### Features
+
+- **functional**: Add pull mirror test
+  ([`3b31ade`](https://github.com/python-gitlab/python-gitlab/commit/3b31ade152eb61363a68cf0509867ff8738ccdaf))
+
+- **projects**: Add pull mirror class
+  ([`2411bff`](https://github.com/python-gitlab/python-gitlab/commit/2411bff4fd1dab6a1dd70070441b52e9a2927a63))
+
+- **unit**: Add pull mirror tests
+  ([`5c11203`](https://github.com/python-gitlab/python-gitlab/commit/5c11203a8b281f6ab34f7e85073fadcfc395503c))
+
+
+## v5.4.0 (2025-01-28)
+
+### Bug Fixes
+
+- **api**: Make type ignores more specific where possible
+  ([`e3cb806`](https://github.com/python-gitlab/python-gitlab/commit/e3cb806dc368af0a495087531ee94892d3f240ce))
+
+Instead of using absolute ignore `# type: ignore` use a more specific ignores like `# type:
+  ignore[override]`. This might help in the future where a new bug might be introduced and get
+  ignored by a general ignore comment but not a more specific one.
+
+Signed-off-by: Igor Ponomarev <igor.ponomarev@collabora.com>
+
+- **api**: Return the new commit when calling cherry_pick
+  ([`de29503`](https://github.com/python-gitlab/python-gitlab/commit/de29503262b7626421f3bffeea3ff073e63e3865))
+
+- **files**: Add optional ref parameter for cli project-file raw (python-gitlab#3032)
+  ([`22f03bd`](https://github.com/python-gitlab/python-gitlab/commit/22f03bdc2bac92138225563415f5cf6fa36a5644))
+
+The ef parameter was removed in python-gitlab v4.8.0. This will add ef back as an optional parameter
+  for the project-file raw cli command.
+
+### Chores
+
+- Fix missing space in deprecation message
+  ([`ba75c31`](https://github.com/python-gitlab/python-gitlab/commit/ba75c31e4d13927b6a3ab0ce427800d94e5eefb4))
+
+- Fix pytest deprecation
+  ([`95db680`](https://github.com/python-gitlab/python-gitlab/commit/95db680d012d73e7e505ee85db7128050ff0db6e))
+
+pytest has changed the function argument name to `start_path`
+
+- Fix warning being generated
+  ([`0eb5eb0`](https://github.com/python-gitlab/python-gitlab/commit/0eb5eb0505c5b837a2d767cfa256a25b64ceb48b))
+
+The CI shows a warning. Use `get_all=False` to resolve issue.
+
+- Resolve DeprecationWarning message in CI run
+  ([`accd5aa`](https://github.com/python-gitlab/python-gitlab/commit/accd5aa757ba5215497c278da50d48f10ea5a258))
+
+Catch the DeprecationWarning in our test, as we expect it.
+
+- **ci**: Set a 30 minute timeout for 'functional' tests
+  ([`e8d6953`](https://github.com/python-gitlab/python-gitlab/commit/e8d6953ec06dbbd817852207abbbc74eab8a27cf))
+
+Currently the functional API test takes around 17 minutes to run. And the functional CLI test takes
+  around 12 minutes to run.
+
+Occasionally a job gets stuck and will sit until the default 360 minutes job timeout occurs.
+
+Now have a 30 minute timeout for the 'functional' tests.
+
+- **deps**: Update all non-major dependencies
+  ([`939505b`](https://github.com/python-gitlab/python-gitlab/commit/939505b9c143939ba1e52c5cb920d8aa36596e19))
+
+- **deps**: Update all non-major dependencies
+  ([`cbd4263`](https://github.com/python-gitlab/python-gitlab/commit/cbd4263194fcbad9d6c11926862691f8df0dea6d))
+
+- **deps**: Update gitlab ([#3088](https://github.com/python-gitlab/python-gitlab/pull/3088),
+  [`9214b83`](https://github.com/python-gitlab/python-gitlab/commit/9214b8371652be2371823b6f3d531eeea78364c7))
+
+Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
+
+- **deps**: Update gitlab/gitlab-ee docker tag to v17.7.1-ee.0
+  ([#3082](https://github.com/python-gitlab/python-gitlab/pull/3082),
+  [`1e95944`](https://github.com/python-gitlab/python-gitlab/commit/1e95944119455875bd239752cdf0fe5cc27707ea))
+
+Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
+
+- **deps**: Update mypy to 1.14 and resolve issues
+  ([`671e711`](https://github.com/python-gitlab/python-gitlab/commit/671e711c341d28ae0bc61ccb12d2e986353473fd))
+
+mypy 1.14 has a change to Enum Membership Semantics:
+  https://mypy.readthedocs.io/en/latest/changelog.html
+
+Resolve the issues with Enum and typing, and update mypy to 1.14
+
+- **test**: Prevent 'job_with_artifact' fixture running forever
+  ([`e4673d8`](https://github.com/python-gitlab/python-gitlab/commit/e4673d8aeaf97b9ad5d2500e459526b4cf494547))
+
+Previously the 'job_with_artifact' fixture could run forever. Now give it up to 60 seconds to
+  complete before failing.
+
+### Continuous Integration
+
+- Use gitlab-runner:v17.7.1 for the CI
+  ([`2dda9dc`](https://github.com/python-gitlab/python-gitlab/commit/2dda9dc149668a99211daaa1981bb1f422c63880))
+
+The `latest` gitlab-runner image does not have the `gitlab-runner` user and it causes our tests to
+  fail.
+
+Closes: #3091
+
+### Features
+
+- **api**: Add argument that appends extra HTTP headers to a request
+  ([`fb07b5c`](https://github.com/python-gitlab/python-gitlab/commit/fb07b5cfe1d986c3a7cd7879b11ecc43c75542b7))
+
+Currently the only way to manipulate the headers for a request is to use `Gitlab.headers` attribute.
+  However, this makes it very concurrently unsafe because the `Gitlab` object can be shared between
+  multiple requests at the same time.
+
+Instead add a new keyword argument `extra_headers` which will update the headers dictionary with new
+  values just before the request is sent.
+
+For example, this can be used to download a part of a artifacts file using the `Range` header:
+  https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests
+
+Signed-off-by: Igor Ponomarev <igor.ponomarev@collabora.com>
+
+- **api**: Add support for external status check
+  ([`175b355`](https://github.com/python-gitlab/python-gitlab/commit/175b355d84d54a71f15fe3601c5275dc35984b9b))
+
+- **api**: Narrow down return type of download methods using typing.overload
+  ([`44fd9dc`](https://github.com/python-gitlab/python-gitlab/commit/44fd9dc1176a2c5529c45cc3186c0e775026175e))
+
+Currently the download methods such as `ProjectJob.artifacts` have return type set to
+  `Optional[Union[bytes, Iterator[Any]]]` which means they return either `None` or `bytes` or
+  `Iterator[Any]`.
+
+However, the actual return type is determined by the passed `streamed` and `iterator` arguments.
+  Using `@typing.overload` decorator it is possible to return a single type based on the passed
+  arguments.
+
+Add overloads in the following order to all download methods:
+
+1. If `streamed=False` and `iterator=False` return `bytes`. This is the default argument values
+  therefore it should be first as it will be used to lookup default arguments. 2. If `iterator=True`
+  return `Iterator[Any]`. This can be combined with both `streamed=True` and `streamed=False`. 3. If
+  `streamed=True` and `iterator=False` return `None`. In this case `action` argument can be set to a
+  callable that accepts `bytes`.
+
+Signed-off-by: Igor Ponomarev <igor.ponomarev@collabora.com>
+
+- **api**: Narrow down return type of ProjectFileManager.raw using typing.overload
+  ([`36d9b24`](https://github.com/python-gitlab/python-gitlab/commit/36d9b24ff27d8df514c1beebd0fff8ad000369b7))
+
+This is equivalent to the changes in 44fd9dc1176a2c5529c45cc3186c0e775026175e but for
+  `ProjectFileManager.raw` method that I must have missed in the original commit.
+
+Signed-off-by: Igor Ponomarev <igor.ponomarev@collabora.com>
+
+
+## v5.3.1 (2025-01-07)
+
+### Bug Fixes
+
+- **api**: Allow configuration of keep_base_url from file
+  ([`f4f7d7a`](https://github.com/python-gitlab/python-gitlab/commit/f4f7d7a63716f072eb45db2c7f590db0435350f0))
+
+- **registry-protection**: Fix api url
+  ([`8c1aaa3`](https://github.com/python-gitlab/python-gitlab/commit/8c1aaa3f6a797caf7bd79a7da083eae56c6250ff))
+
+See:
+  https://docs.gitlab.com/ee/api/container_repository_protection_rules.html#list-container-repository-protection-rules
+
+### Chores
+
+- Bump to 5.3.1
+  ([`912e1a0`](https://github.com/python-gitlab/python-gitlab/commit/912e1a0620a96c56081ffec284c2cac871cb7626))
+
+- **deps**: Update dependency jinja2 to v3.1.5 [security]
+  ([`01d4194`](https://github.com/python-gitlab/python-gitlab/commit/01d41946cbb1a4e5f29752eac89239d635c2ec6f))
+
+
+## v5.3.0 (2024-12-28)
+
+### Chores
+
+- **deps**: Update gitlab/gitlab-ee docker tag to v17.7.0-ee.0
+  ([#3070](https://github.com/python-gitlab/python-gitlab/pull/3070),
+  [`62b7eb7`](https://github.com/python-gitlab/python-gitlab/commit/62b7eb7ca0adcb26912f9c0561de5c513b6ede6d))
+
+Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
+
+- **renovate**: Update httpx and respx again
+  ([`aa07449`](https://github.com/python-gitlab/python-gitlab/commit/aa074496bdc4390a3629f1b0964d9846fe08ad92))
+
+### Features
+
+- **api**: Support the new registry protection rule endpoint
+  ([`40af1c8`](https://github.com/python-gitlab/python-gitlab/commit/40af1c8a14814cb0034dfeaaa33d8c38504fe34e))
+
+
+## v5.2.0 (2024-12-17)
+
+### Chores
+
+- **deps**: Update all non-major dependencies
+  ([`1e02f23`](https://github.com/python-gitlab/python-gitlab/commit/1e02f232278a85f818230b8931e2627c80a50e38))
+
+- **deps**: Update all non-major dependencies
+  ([`6532e8c`](https://github.com/python-gitlab/python-gitlab/commit/6532e8c7a9114f5abbfd610c65bd70d09576b146))
+
+- **deps**: Update all non-major dependencies
+  ([`8046387`](https://github.com/python-gitlab/python-gitlab/commit/804638777f22b23a8b9ea54ffce19852ea6d9366))
+
+- **deps**: Update codecov/codecov-action action to v5
+  ([`735efff`](https://github.com/python-gitlab/python-gitlab/commit/735efff88cc8d59021cb5a746ba70b66548e7662))
+
+- **deps**: Update dependency commitizen to v4
+  ([`9306362`](https://github.com/python-gitlab/python-gitlab/commit/9306362a14cae32b13f59630ea9a964783fa8de8))
+
+- **deps**: Update gitlab/gitlab-ee docker tag to v17.6.1-ee.0
+  ([#3053](https://github.com/python-gitlab/python-gitlab/pull/3053),
+  [`f2992ae`](https://github.com/python-gitlab/python-gitlab/commit/f2992ae57641379c4ed6ac1660e9c1f9237979af))
+
+Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
+
+- **deps**: Update gitlab/gitlab-ee docker tag to v17.6.2-ee.0
+  ([#3065](https://github.com/python-gitlab/python-gitlab/pull/3065),
+  [`db0db26`](https://github.com/python-gitlab/python-gitlab/commit/db0db26734533d1a95225dc1a5dd2ae0b03c6053))
+
+Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
+
+- **deps**: Update pre-commit hook commitizen-tools/commitizen to v4
+  ([`a8518f1`](https://github.com/python-gitlab/python-gitlab/commit/a8518f1644b32039571afb4172738dcde169bec0))
+
+- **docs**: Fix CHANGELOG tracebacks codeblocks
+  ([`9fe372a`](https://github.com/python-gitlab/python-gitlab/commit/9fe372a8898fed25d8bca8eedcf42560448380e4))
+
+With v5.1.0 CHANGELOG.md was updated that mangled v1.10.0 triple backtick codeblock Traceback output
+  that made sphinx fail [1] with a non-zero return code.
+
+The resulting docs appears to be processes as text after the failing line [2]. While reviewing other
+  backtick codeblocks fix v1.8.0 [3] to the original traceback.
+
+[1]
+  https://github.com/python-gitlab/python-gitlab/actions/runs/12060608158/job/33631303063#step:5:204
+  [2] https://python-gitlab.readthedocs.io/en/v5.1.0/changelog.html#v1-10-0-2019-07-22 [3]
+  https://python-gitlab.readthedocs.io/en/v5.0.0/changelog.html#id258
+
+- **renovate**: Pin httpx until respx is fixed
+  ([`b70830d`](https://github.com/python-gitlab/python-gitlab/commit/b70830dd3ad76ff537a1f81e9f69de72271a2305))
+
+### Documentation
+
+- **api-usage**: Fix link to Gitlab REST API Authentication Docs
+  ([#3059](https://github.com/python-gitlab/python-gitlab/pull/3059),
+  [`f460d95`](https://github.com/python-gitlab/python-gitlab/commit/f460d95cbbb6fcf8d10bc70f53299438843032fd))
+
+### Features
+
+- **api**: Add project templates ([#3057](https://github.com/python-gitlab/python-gitlab/pull/3057),
+  [`0d41da3`](https://github.com/python-gitlab/python-gitlab/commit/0d41da3cc8724ded8a3855409cf9c5d776a7f491))
+
+* feat(api): Added project template classes to templates.py * feat(api): Added project template
+  managers to Project in project.py * docs(merge_requests): Add example of creating mr with
+  description template * test(templates): Added unit tests for templates * docs(templates): added
+  section for project templates
+
+- **graphql**: Add async client
+  ([`288f39c`](https://github.com/python-gitlab/python-gitlab/commit/288f39c828eb6abd8f05744803142beffed3f288))
+
+
+## v5.1.0 (2024-11-28)
+
+### Chores
+
+- **deps**: Update all non-major dependencies
+  ([`9061647`](https://github.com/python-gitlab/python-gitlab/commit/9061647315f4e3e449cb8096c56b8baa1dbb4b23))
+
+- **deps**: Update all non-major dependencies
+  ([`62da12a`](https://github.com/python-gitlab/python-gitlab/commit/62da12aa79b11b64257cd4b1a6e403964966e224))
+
+- **deps**: Update all non-major dependencies
+  ([`7e62136`](https://github.com/python-gitlab/python-gitlab/commit/7e62136991f694be9c8c76c12f291c60f3607b44))
+
+- **deps**: Update all non-major dependencies
+  ([`d4b52e7`](https://github.com/python-gitlab/python-gitlab/commit/d4b52e789fd131475096817ffd6f5a8e1e5d07c6))
+
+- **deps**: Update all non-major dependencies
+  ([`541a7e3`](https://github.com/python-gitlab/python-gitlab/commit/541a7e3ec3f685eb7c841eeee3be0f1df3d09035))
+
+- **deps**: Update dependency pytest-cov to v6
+  ([`ffa88b3`](https://github.com/python-gitlab/python-gitlab/commit/ffa88b3a45fa5997cafd400cebd6f62acd43ba8e))
+
+- **deps**: Update gitlab/gitlab-ee docker tag to v17.5.1-ee.0
+  ([`8111f49`](https://github.com/python-gitlab/python-gitlab/commit/8111f49e4f91783dbc6d3f0c3fce6eb504f09bb4))
+
+- **deps**: Update gitlab/gitlab-ee docker tag to v17.5.2-ee.0
+  ([#3041](https://github.com/python-gitlab/python-gitlab/pull/3041),
+  [`d39129b`](https://github.com/python-gitlab/python-gitlab/commit/d39129b659def10213821f3e46718c4086e77b4b))
+
+Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
+
+- **deps**: Update gitlab/gitlab-ee docker tag to v17.6.0-ee.0
+  ([#3044](https://github.com/python-gitlab/python-gitlab/pull/3044),
+  [`79113d9`](https://github.com/python-gitlab/python-gitlab/commit/79113d997b3d297fd8e06c6e6e10fe39480cb2f6))
+
+Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
+
+- **deps**: Update pre-commit hook maxbrunet/pre-commit-renovate to v39
+  ([`11458e0`](https://github.com/python-gitlab/python-gitlab/commit/11458e0e0404d1b2496b505509ecb795366a7e64))
+
+### Features
+
+- **api**: Get single project approval rule
+  ([`029695d`](https://github.com/python-gitlab/python-gitlab/commit/029695df80f7370f891e17664522dd11ea530881))
+
+- **api**: Support list and delete for group service accounts
+  ([#2963](https://github.com/python-gitlab/python-gitlab/pull/2963),
+  [`499243b`](https://github.com/python-gitlab/python-gitlab/commit/499243b37cda0c7dcd4b6ce046d42e81845e2a4f))
+
+- **cli**: Enable token rotation via CLI
+  ([`0cb8171`](https://github.com/python-gitlab/python-gitlab/commit/0cb817153d8149dfdfa3dfc28fda84382a807ae2))
+
+- **const**: Add new Planner role to access levels
+  ([`bdc8852`](https://github.com/python-gitlab/python-gitlab/commit/bdc8852051c98b774fd52056992333ff3638f628))
+
+- **files**: Add support for more optional flags
+  ([`f51cd52`](https://github.com/python-gitlab/python-gitlab/commit/f51cd5251c027849effb7e6ad3a01806fb2bda67))
+
+GitLab's Repository Files API supports additional flags that weren't implemented before. Notably,
+  the "start_branch" flag is particularly useful, as previously one had to use the "project-branch"
+  command alongside "project-file" to add a file on a separate branch.
+
+[1] https://docs.gitlab.com/ee/api/repository_files.html
+
+
+## v5.0.0 (2024-10-28)
+
+### Bug Fixes
+
+- **api**: Set _repr_attr for project approval rules to name attr
+  ([#3011](https://github.com/python-gitlab/python-gitlab/pull/3011),
+  [`1a68f1c`](https://github.com/python-gitlab/python-gitlab/commit/1a68f1c5ff93ad77c58276231ee33f58b7083a09))
+
+Co-authored-by: Patrick Evans <patrick.evans@gehealthcare.com>
+
+### Chores
+
+- Add Python 3.13 as supported ([#3012](https://github.com/python-gitlab/python-gitlab/pull/3012),
+  [`b565e78`](https://github.com/python-gitlab/python-gitlab/commit/b565e785d05a1e7f559bfcb0d081b3c2507340da))
+
+Mark that Python 3.13 is supported.
+
+Use Python 3.13 for the Mac and Windows tests.
+
+Also remove the 'py38' tox environment. We no longer support Python 3.8.
+
+- Add testing of Python 3.14
+  ([`14d2a82`](https://github.com/python-gitlab/python-gitlab/commit/14d2a82969cd1b3509526eee29159f15862224a2))
+
+Also fix __annotations__ not working in Python 3.14 by using the annotation on the 'class' instead
+  of on the 'instance'
+
+Closes: #3013
+
+- Remove "v3" question from issue template
+  ([#3017](https://github.com/python-gitlab/python-gitlab/pull/3017),
+  [`482f2fe`](https://github.com/python-gitlab/python-gitlab/commit/482f2fe6ccae9239b3a010a70969d8d887cdb6b6))
+
+python-gitlab hasn't supported the GitLab v3 API since 2018. The last version of python-gitlab to
+  support it was v1.4
+
+Support was removed in:
+
+commit fe89b949922c028830dd49095432ba627d330186 Author: Gauvain Pocentek <gauvain@pocentek.net>
+
+Date: Sat May 19 17:10:08 2018 +0200
+
+Drop API v3 support
+
+Drop the code, the tests, and update the documentation.
+
+- **deps**: Update all non-major dependencies
+  ([`1e4326b`](https://github.com/python-gitlab/python-gitlab/commit/1e4326b393be719616db5a08594facdabfbc1855))
+
+- **deps**: Update all non-major dependencies
+  ([`b3834dc`](https://github.com/python-gitlab/python-gitlab/commit/b3834dceb290c4c3bc97541aea38b02de53638df))
+
+- **deps**: Update dependency ubuntu to v24
+  ([`6fda15d`](https://github.com/python-gitlab/python-gitlab/commit/6fda15dff5e01c9982c9c7e65e302ff06416517e))
+
+- **deps**: Update gitlab/gitlab-ee docker tag to v17.4.2-ee.0
+  ([`1cdfe40`](https://github.com/python-gitlab/python-gitlab/commit/1cdfe40ac0a5334ee13d530e3f6f60352a621892))
+
+- **deps**: Update gitlab/gitlab-ee docker tag to v17.5.0-ee.0
+  ([`c02a392`](https://github.com/python-gitlab/python-gitlab/commit/c02a3927f5294778b1c98128e1e04bcbc40ed821))
+
+### Documentation
+
+- **users**: Update Gitlab docs links
+  ([#3022](https://github.com/python-gitlab/python-gitlab/pull/3022),
+  [`3739b5d`](https://github.com/python-gitlab/python-gitlab/commit/3739b5dd11bed66fb482cf6d2dc34382327a0265))
+
+### Features
+
+- Remove support for Python 3.8, require 3.9 or higher
+  ([#3005](https://github.com/python-gitlab/python-gitlab/pull/3005),
+  [`9734ad4`](https://github.com/python-gitlab/python-gitlab/commit/9734ad4bcbedcf4ee61317c12f47ddacf2ac208f))
+
+Python 3.8 is End-of-Life (EOL) as of 2024-10 as stated in https://devguide.python.org/versions/ and
+  https://peps.python.org/pep-0569/#lifespan
+
+By dropping support for Python 3.8 and requiring Python 3.9 or higher it allows python-gitlab to
+  take advantage of new features in Python 3.9, which are documented at:
+  https://docs.python.org/3/whatsnew/3.9.html
+
+Closes: #2968
+
+BREAKING CHANGE: As of python-gitlab 5.0.0, Python 3.8 is no longer supported. Python 3.9 or higher
+  is required.
+
+### Testing
+
+- Add test for `to_json()` method
+  ([`f4bfe19`](https://github.com/python-gitlab/python-gitlab/commit/f4bfe19b5077089ea1d3bf07e8718d29de7d6594))
+
+This should get us to 100% test coverage on `gitlab/base.py`
+
+### BREAKING CHANGES
+
+- As of python-gitlab 5.0.0, Python 3.8 is no longer supported. Python 3.9 or higher is required.
+
+
+## v4.13.0 (2024-10-08)
+
+### Chores
+
+- **deps**: Update all non-major dependencies
+  ([`c3efb37`](https://github.com/python-gitlab/python-gitlab/commit/c3efb37c050268de3f1ef5e24748ccd9487e346d))
+
+- **deps**: Update dependency pre-commit to v4
+  ([#3008](https://github.com/python-gitlab/python-gitlab/pull/3008),
+  [`5c27546`](https://github.com/python-gitlab/python-gitlab/commit/5c27546d35ced76763ea8b0071b4ec4c896893a1))
+
+Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
+
+### Features
+
+- **api**: Add support for project Pages API
+  ([`0ee0e02`](https://github.com/python-gitlab/python-gitlab/commit/0ee0e02f1d1415895f6ab0f6d23b39b50a36446a))
+
+
+## v4.12.2 (2024-10-01)
+
+### Bug Fixes
+
+- Raise GitlabHeadError in `project.files.head()` method
+  ([#3006](https://github.com/python-gitlab/python-gitlab/pull/3006),
+  [`9bf26df`](https://github.com/python-gitlab/python-gitlab/commit/9bf26df9d1535ca2881c43706a337a972b737fa0))
+
+When an error occurs, raise `GitlabHeadError` in `project.files.head()` method.
+
+Closes: #3004
+
+
+## v4.12.1 (2024-09-30)
+
+### Bug Fixes
+
+- **ci**: Do not rely on GitLab.com runner arch variables
+  ([#3003](https://github.com/python-gitlab/python-gitlab/pull/3003),
+  [`c848d12`](https://github.com/python-gitlab/python-gitlab/commit/c848d12252763c32fc2b1c807e7d9887f391a761))
+
+- **files**: Correctly raise GitlabGetError in get method
+  ([`190ec89`](https://github.com/python-gitlab/python-gitlab/commit/190ec89bea12d7eec719a6ea4d15706cfdacd159))
+
+### Chores
+
+- **deps**: Update all non-major dependencies
+  ([#3000](https://github.com/python-gitlab/python-gitlab/pull/3000),
+  [`d3da326`](https://github.com/python-gitlab/python-gitlab/commit/d3da326828274ed0c5f76b01a068519d360995c8))
+
+Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
+
+- **deps**: Update gitlab/gitlab-ee docker tag to v17.4.1-ee.0
+  ([`64eed5d`](https://github.com/python-gitlab/python-gitlab/commit/64eed5d388252135a42a252b9100ffc75d9fb0ea))
+
+
+## v4.12.0 (2024-09-28)
+
+### Bug Fixes
+
+- **api**: Head requests for projectfilemanager
+  ([#2977](https://github.com/python-gitlab/python-gitlab/pull/2977),
+  [`96a18b0`](https://github.com/python-gitlab/python-gitlab/commit/96a18b065dac4ce612a128f03e2fc6d1b4ccd69e))
+
+* fix(api): head requests for projectfilemanager
+
+---------
+
+Co-authored-by: Patrick Evans <patrick.evans@gehealthcare.com>
+
+Co-authored-by: Nejc Habjan <hab.nejc@gmail.com>
+
+### Chores
+
+- Update pylint to 3.3.1 and resolve issues
+  ([#2997](https://github.com/python-gitlab/python-gitlab/pull/2997),
+  [`a0729b8`](https://github.com/python-gitlab/python-gitlab/commit/a0729b83e63bcd74f522bf57a87a5800b1cf19d1))
+
+pylint 3.3.1 appears to have added "too-many-positional-arguments" check with a value of 5.
+
+I don't disagree with this, but we have many functions which exceed this value. We might think about
+  converting some of positional arguments over to keyword arguments in the future. But that is for
+  another time.
+
+For now disable the check across the project.
+
+- **deps**: Update all non-major dependencies
+  ([`ae132e7`](https://github.com/python-gitlab/python-gitlab/commit/ae132e7a1efef6b0ae2f2a7d335668784648e3c7))
+
+- **deps**: Update all non-major dependencies
+  ([`10ee58a`](https://github.com/python-gitlab/python-gitlab/commit/10ee58a01fdc8071f29ae0095d9ea8a4424fa728))
+
+- **deps**: Update dependency types-setuptools to v75
+  ([`a2ab54c`](https://github.com/python-gitlab/python-gitlab/commit/a2ab54ceb40eca1e6e71f7779a418591426b2b2c))
+
+- **deps**: Update gitlab/gitlab-ee docker tag to v17.3.2-ee.0
+  ([`5cd1ab2`](https://github.com/python-gitlab/python-gitlab/commit/5cd1ab202e3e7b64d626d2c4e62b1662a4285015))
+
+- **deps**: Update gitlab/gitlab-ee docker tag to v17.4.0-ee.0
+  ([`8601808`](https://github.com/python-gitlab/python-gitlab/commit/860180862d952ed25cf95df1a4f825664f7e1c4b))
+
+### Features
+
+- Introduce related_issues to merge requests
+  ([#2996](https://github.com/python-gitlab/python-gitlab/pull/2996),
+  [`174d992`](https://github.com/python-gitlab/python-gitlab/commit/174d992e49f1e5171fee8893a1713f30324bbf97))
+
+- **build**: Build multi-arch images
+  ([#2987](https://github.com/python-gitlab/python-gitlab/pull/2987),
+  [`29f617d`](https://github.com/python-gitlab/python-gitlab/commit/29f617d7d368636791baf703ecdbd22583356674))
+
+
+## v4.11.1 (2024-09-13)
+
+### Bug Fixes
+
+- **client**: Ensure type evaluations are postponed
+  ([`b41b2de`](https://github.com/python-gitlab/python-gitlab/commit/b41b2de8884c2dc8c8be467f480c7161db6a1c87))
+
+
+## v4.11.0 (2024-09-13)
+
+### Chores
+
+- **deps**: Update all non-major dependencies
+  ([`fac8bf9`](https://github.com/python-gitlab/python-gitlab/commit/fac8bf9f3e2a0218f96337536d08dec9991bfc1a))
+
+- **deps**: Update all non-major dependencies
+  ([`88c7529`](https://github.com/python-gitlab/python-gitlab/commit/88c75297377dd1f1106b5bc673946cebd563e0a1))
+
+- **deps**: Update dependency types-setuptools to v74
+  ([`bdfaddb`](https://github.com/python-gitlab/python-gitlab/commit/bdfaddb89ae7ba351bd3a21c6cecc528772db4de))
+
+- **pre-commit**: Add deps
+  ([`fe5e608`](https://github.com/python-gitlab/python-gitlab/commit/fe5e608bc6cc04863bd4d1d9dbe101fffd88e954))
+
+### Documentation
+
+- **objects**: Fix typo in get latest pipeline
+  ([`b9f5c12`](https://github.com/python-gitlab/python-gitlab/commit/b9f5c12d3ba6ca4e4321a81e7610d03fb4440c02))
+
+### Features
+
+- Add a minimal GraphQL client
+  ([`d6b1b0a`](https://github.com/python-gitlab/python-gitlab/commit/d6b1b0a962bbf0f4e0612067fc075dbdcbb772f8))
+
+- **api**: Add exclusive GET attrs for /groups/:id/members
+  ([`d44ddd2`](https://github.com/python-gitlab/python-gitlab/commit/d44ddd2b00d78bb87ff6a4776e64e05e0c1524e1))
+
+- **api**: Add exclusive GET attrs for /projects/:id/members
+  ([`e637808`](https://github.com/python-gitlab/python-gitlab/commit/e637808bcb74498438109d7ed352071ebaa192d5))
+
+- **client**: Add retry handling to GraphQL client
+  ([`8898c38`](https://github.com/python-gitlab/python-gitlab/commit/8898c38b97ed36d9ff8f2f20dee27ef1448b9f83))
+
+- **client**: Make retries configurable in GraphQL
+  ([`145870e`](https://github.com/python-gitlab/python-gitlab/commit/145870e628ed3b648a0a29fc551a6f38469b684a))
+
+### Refactoring
+
+- **client**: Move retry logic into utility
+  ([`3235c48`](https://github.com/python-gitlab/python-gitlab/commit/3235c48328c2866f7d46597ba3c0c2488e6c375c))
+
+
+## v4.10.0 (2024-08-28)
+
+### Chores
+
+- **deps**: Update all non-major dependencies
+  ([`2ade0d9`](https://github.com/python-gitlab/python-gitlab/commit/2ade0d9f4922226143e2e3835a7449fde9c49d66))
+
+- **deps**: Update all non-major dependencies
+  ([`0578bf0`](https://github.com/python-gitlab/python-gitlab/commit/0578bf07e7903037ffef6558e914766b6cf6f545))
+
+- **deps**: Update all non-major dependencies
+  ([`31786a6`](https://github.com/python-gitlab/python-gitlab/commit/31786a60da4b9a10dec0eab3a0b078aa1e94d809))
+
+- **deps**: Update dependency myst-parser to v4
+  ([`930d4a2`](https://github.com/python-gitlab/python-gitlab/commit/930d4a21b8afed833b4b2e6879606bbadaee19a1))
+
+- **deps**: Update dependency sphinx to v8
+  ([`cb65ffb`](https://github.com/python-gitlab/python-gitlab/commit/cb65ffb6957bf039f35926d01f15db559e663915))
+
+- **deps**: Update dependency types-setuptools to v73
+  ([`d55c045`](https://github.com/python-gitlab/python-gitlab/commit/d55c04502bee0fb42e2ef359cde3bc1b4b510b1a))
+
+- **deps**: Update gitlab/gitlab-ee docker tag to v17.2.2-ee.0
+  ([`b2275f7`](https://github.com/python-gitlab/python-gitlab/commit/b2275f767dd620c6cb2c27b0470f4e8151c76550))
+
+- **deps**: Update gitlab/gitlab-ee docker tag to v17.3.0-ee.0
+  ([`e5a46f5`](https://github.com/python-gitlab/python-gitlab/commit/e5a46f57de166f94e01f5230eb6ad91f319791e4))
+
+- **deps**: Update gitlab/gitlab-ee docker tag to v17.3.1-ee.0
+  ([`3fdd130`](https://github.com/python-gitlab/python-gitlab/commit/3fdd130a8e87137e5a048d5cb78e43aa476c8f34))
+
+- **deps**: Update python-semantic-release/upload-to-gh-release digest to 17c75b7
+  ([`12caaa4`](https://github.com/python-gitlab/python-gitlab/commit/12caaa496740cb15e6220511751b7a20e2d29d07))
+
+- **release**: Track tags for renovate
+  ([`d600444`](https://github.com/python-gitlab/python-gitlab/commit/d6004449ad5aaaf2132318a78523818996ec3e21))
+
+### Documentation
+
+- **faq**: Correct the attribute fetching example
+  ([`43a16ac`](https://github.com/python-gitlab/python-gitlab/commit/43a16ac17ce78cf18e0fc10fa8229f052eed3946))
+
+There is an example about object attributes in the FAQ. It shows how to properly fetch all
+  attributes of all projects, by using list() followed by a get(id) call.
+
+Unfortunately this example used a wrong variable name, which caused it not to work and which could
+  have made it slightly confusing to readers. This commit fixes that, by changing the variable name.
+
+Now the example uses one variable for two Python objects. As they correspond to the same GitLab
+  object and the intended behavior is to obtain that very object, just with all attributes, this is
+  fine and is probably what readers will find most useful in this context.
+
+### Features
+
+- **api**: Project/group hook test triggering
+  ([`9353f54`](https://github.com/python-gitlab/python-gitlab/commit/9353f5406d6762d09065744bfca360ccff36defe))
+
+Add the ability to trigger tests of project and group hooks.
+
+Fixes #2924
+
+### Testing
+
+- **cli**: Allow up to 30 seconds for a project export
+  ([`bdc155b`](https://github.com/python-gitlab/python-gitlab/commit/bdc155b716ef63ef1398ee1e6f5ca67da1109c13))
+
+Before we allowed a maximum of around 15 seconds for the project-export. Often times the CI was
+  failing with this value.
+
+Change it to a maximum of around 30 seconds.
+
+
+## v4.9.0 (2024-08-06)
+
+### Chores
+
+- **ci**: Make pre-commit check happy
+  ([`67370d8`](https://github.com/python-gitlab/python-gitlab/commit/67370d8f083ddc34c0acf0c0b06742a194dfa735))
+
+pre-commit incorrectly wants double back-quotes inside the code section. Rather than fight it, just
+  use single quotes.
+
+- **deps**: Update all non-major dependencies
+  ([`f95ca26`](https://github.com/python-gitlab/python-gitlab/commit/f95ca26b411e5a8998eb4b81e41c061726271240))
+
+- **deps**: Update all non-major dependencies
+  ([`7adc86b`](https://github.com/python-gitlab/python-gitlab/commit/7adc86b2e202cad42776991f0ed8c81517bb37ad))
+
+- **deps**: Update all non-major dependencies
+  ([`e820db0`](https://github.com/python-gitlab/python-gitlab/commit/e820db0d9db42a826884b45a76267fee861453d4))
+
+- **deps**: Update dependency types-setuptools to v71
+  ([`d6a7dba`](https://github.com/python-gitlab/python-gitlab/commit/d6a7dba600923e582064a77579dea82281871c25))
+
+- **deps**: Update gitlab/gitlab-ee docker tag to v17.2.1-ee.0
+  ([`d13a656`](https://github.com/python-gitlab/python-gitlab/commit/d13a656565898886cc6ba11028b3bcb719c21f0f))
+
+- **deps**: Update pre-commit hook maxbrunet/pre-commit-renovate to v38
+  ([`f13968b`](https://github.com/python-gitlab/python-gitlab/commit/f13968be9e2bb532f3c1185c1fa4185c05335552))
+
+- **deps**: Update python-semantic-release/upload-to-gh-release digest to 0dcddac
+  ([`eb5c6f7`](https://github.com/python-gitlab/python-gitlab/commit/eb5c6f7fb6487da21c69582adbc69aaf36149143))
+
+- **deps**: Update python-semantic-release/upload-to-gh-release digest to e2355e1
+  ([`eb18552`](https://github.com/python-gitlab/python-gitlab/commit/eb18552e423e270a27a2b205bfd2f22fcb2eb949))
+
+### Features
+
+- **snippets**: Add support for listing all instance snippets
+  ([`64ae61e`](https://github.com/python-gitlab/python-gitlab/commit/64ae61ed9ba60169037703041c2a9a71017475b9))
+
+
+## v4.8.0 (2024-07-16)
+
+### Bug Fixes
+
+- Have `participants()` method use `http_list()`
+  ([`d065275`](https://github.com/python-gitlab/python-gitlab/commit/d065275f2fe296dd00e9bbd0f676d1596f261a85))
+
+Previously it was using `http_get()` but the `participants` API returns a list of participants. Also
+  by using this then we will warn if only a subset of the participants are returned.
+
+Closes: #2913
+
+- Issues `closed_by()/related_merge_requests()` use `http_list`
+  ([`de2e4dd`](https://github.com/python-gitlab/python-gitlab/commit/de2e4dd7e80c7b84fd41458117a85558fcbac32d))
+
+The `closed_by()` and `related_merge_requests()` API calls return lists. So use the `http_list()`
+  method.
+
+This will also warn the user if only a subset of the data is returned.
+
+- **cli**: Generate UserWarning if `list` does not return all entries
+  ([`e5a4379`](https://github.com/python-gitlab/python-gitlab/commit/e5a43799b5039261d7034af909011444718a5814))
+
+Previously in the CLI, calls to `list()` would have `get_all=False` by default. Therefore hiding the
+  fact that not all items are being returned if there were more than 20 items.
+
+Added `--no-get-all` option to `list` actions. Along with the already existing `--get-all`.
+
+Closes: #2900
+
+- **files**: Cr: add explicit comparison to `None`
+  ([`51d8f88`](https://github.com/python-gitlab/python-gitlab/commit/51d8f888aca469cff1c5ee5e158fb259d2862017))
+
+Co-authored-by: Nejc Habjan <hab.nejc@gmail.com>
+
+- **files**: Make `ref` parameter optional in get raw file api
+  ([`00640ac`](https://github.com/python-gitlab/python-gitlab/commit/00640ac11f77e338919d7e9a1457d111c82af371))
+
+The `ref` parameter was made optional in gitlab v13.11.0.
+
+### Chores
+
+- Add `show_caller` argument to `utils.warn()`
+  ([`7d04315`](https://github.com/python-gitlab/python-gitlab/commit/7d04315d7d9641d88b0649e42bf24dd160629af5))
+
+This allows us to not add the caller's location to the UserWarning message.
+
+- Use correct type-hint for `die()`
+  ([`9358640`](https://github.com/python-gitlab/python-gitlab/commit/93586405fbfa61317dc75e186799549573bc0bbb))
+
+- **ci**: Specify name of "stale" label
+  ([`44f62c4`](https://github.com/python-gitlab/python-gitlab/commit/44f62c49106abce2099d5bb1f3f97b64971da406))
+
+Saw the following error in the log: [#2618] Removing the label "Stale" from this issue...
+  ##[error][#2618] Error when removing the label: "Label does not exist"
+
+My theory is that the case doesn't match ("Stale" != "stale") and that is why it failed. Our label
+  is "stale" so update this to match. Thought of changing the label name on GitHub but then would
+  also require a change here to the "any-of-labels". So it seemed simpler to just change it here.
+
+It is confusing though that it detected the label "stale", but then couldn't delete it.
+
+- **ci**: Stale: allow issues/PRs that have stale label to be closed
+  ([`2ab88b2`](https://github.com/python-gitlab/python-gitlab/commit/2ab88b25a64bd8e028cee2deeb842476de54b109))
+
+If a `stale` label is manually applied, allow the issue or PR to be closed by the stale job.
+
+Previously it would require the `stale` label and to also have one of 'need info' or 'Waiting for
+  response' labels added.
+
+- **ci**: Use codecov token when available
+  ([`b74a6fb`](https://github.com/python-gitlab/python-gitlab/commit/b74a6fb5157e55d3e4471a0c5c8378fed8075edc))
+
+- **deps**: Update all non-major dependencies
+  ([`4a2b213`](https://github.com/python-gitlab/python-gitlab/commit/4a2b2133b52dac102d6f623bf028bdef6dd5a92f))
+
+- **deps**: Update all non-major dependencies
+  ([`0f59069`](https://github.com/python-gitlab/python-gitlab/commit/0f59069420f403a17f67a5c36c81485c9016b59b))
+
+- **deps**: Update all non-major dependencies
+  ([`cf87226`](https://github.com/python-gitlab/python-gitlab/commit/cf87226a81108fbed4f58751f1c03234cc57bcf1))
+
+- **deps**: Update gitlab/gitlab-ee docker tag to v17.1.1-ee.0
+  ([`5e98510`](https://github.com/python-gitlab/python-gitlab/commit/5e98510a6c918b33c0db0a7756e8a43a8bdd868a))
+
+- **deps**: Update gitlab/gitlab-ee docker tag to v17.1.2-ee.0
+  ([`6fedfa5`](https://github.com/python-gitlab/python-gitlab/commit/6fedfa546120942757ea48337ce7446914eb3813))
+
+- **deps**: Update python-semantic-release/upload-to-gh-release digest to c7c3b69
+  ([`23393fa`](https://github.com/python-gitlab/python-gitlab/commit/23393faa0642c66a991fd88f1d2d68aed1d2f172))
+
+- **deps**: Update python-semantic-release/upload-to-gh-release digest to fe6cc89
+  ([`3f3ad80`](https://github.com/python-gitlab/python-gitlab/commit/3f3ad80ef5bb2ed837adceae061291b2b5545ed3))
+
+### Documentation
+
+- Document how to use `sudo` if modifying an object
+  ([`d509da6`](https://github.com/python-gitlab/python-gitlab/commit/d509da60155e9470dee197d91926850ea9548de9))
+
+Add a warning about using `sudo` when saving.
+
+Give an example of how to `get` an object, modify it, and then `save` it using `sudo`
+
+Closes: #532
+
+- Variables: add note about `filter` for updating
+  ([`c378817`](https://github.com/python-gitlab/python-gitlab/commit/c378817389a9510ef508b5a3c90282e5fb60049f))
+
+Add a note about using `filter` when updating a variable.
+
+Closes: #2835
+
+Closes: #1387
+
+Closes: #1125
+
+### Features
+
+- **api**: Add support for commit sequence
+  ([`1f97be2`](https://github.com/python-gitlab/python-gitlab/commit/1f97be2a540122cb872ff59500d85a35031cab5f))
+
+- **api**: Add support for container registry protection rules
+  ([`6d31649`](https://github.com/python-gitlab/python-gitlab/commit/6d31649190279a844bfa591a953b0556cd6fc492))
+
+- **api**: Add support for package protection rules
+  ([`6b37811`](https://github.com/python-gitlab/python-gitlab/commit/6b37811c3060620afd8b81e54a99d96e4e094ce9))
+
+- **api**: Add support for project cluster agents
+  ([`32dbc6f`](https://github.com/python-gitlab/python-gitlab/commit/32dbc6f2bee5b22d18c4793f135223d9b9824d15))
+
+### Refactoring
+
+- **package_protection_rules**: Add missing attributes
+  ([`c307dd2`](https://github.com/python-gitlab/python-gitlab/commit/c307dd20e3df61b118b3b1a8191c0f1880bc9ed6))
+
+### Testing
+
+- **files**: Omit optional `ref` parameter in test case
+  ([`9cb3396`](https://github.com/python-gitlab/python-gitlab/commit/9cb3396d3bd83e82535a2a173b6e52b4f8c020f4))
+
+- **files**: Test with and without `ref` parameter in test case
+  ([`f316b46`](https://github.com/python-gitlab/python-gitlab/commit/f316b466c04f8ff3c0cca06d0e18ddf2d62d033c))
+
+- **fixtures**: Remove deprecated config option
+  ([`2156949`](https://github.com/python-gitlab/python-gitlab/commit/2156949866ce95af542c127ba4b069e83fcc8104))
+
+- **registry**: Disable functional tests for unavailable endpoints
+  ([`ee393a1`](https://github.com/python-gitlab/python-gitlab/commit/ee393a16e1aa6dbf2f9785eb3ef486f7d5b9276f))
+
+
+## v4.7.0 (2024-06-28)
+
+### Bug Fixes
+
+- Add ability to add help to custom_actions
+  ([`9acd2d2`](https://github.com/python-gitlab/python-gitlab/commit/9acd2d23dd8c87586aa99c70b4b47fa47528472b))
+
+Now when registering a custom_action can add help text if desired.
+
+Also delete the VerticalHelpFormatter as no longer needed. When the help value is set to `None` or
+  some other value, the actions will get printed vertically. Before when the help value was not set
+  the actions would all get put onto one line.
+
+### Chores
+
+- Add a help message for `gitlab project-key enable`
+  ([`1291dbb`](https://github.com/python-gitlab/python-gitlab/commit/1291dbb588d3a5a54ee54d9bb93c444ce23efa8c))
+
+Add some help text for `gitlab project-key enable`. This both adds help text and shows how to use
+  the new `help` feature.
+
+Example:
+
+$ gitlab project-key --help usage: gitlab project-key [-h] {list,get,create,update,delete,enable}
+  ...
+
+options: -h, --help show this help message and exit
+
+action: {list,get,create,update,delete,enable} Action to execute on the GitLab resource. list List
+  the GitLab resources get Get a GitLab resource create Create a GitLab resource update Update a
+  GitLab resource delete Delete a GitLab resource enable Enable a deploy key for the project
+
+- Sort CLI behavior-related args to remove
+  ([`9b4b0ef`](https://github.com/python-gitlab/python-gitlab/commit/9b4b0efa1ccfb155aee8384de9e00f922b989850))
+
+Sort the list of CLI behavior-related args that are to be removed.
+
+- **deps**: Update all non-major dependencies
+  ([`88de2f0`](https://github.com/python-gitlab/python-gitlab/commit/88de2f0fc52f4f02e1d44139f4404acf172624d7))
+
+- **deps**: Update all non-major dependencies
+  ([`a510f43`](https://github.com/python-gitlab/python-gitlab/commit/a510f43d990c3a3fd169854218b64d4eb9491628))
+
+- **deps**: Update all non-major dependencies
+  ([`d4fdf90`](https://github.com/python-gitlab/python-gitlab/commit/d4fdf90655c2cb5124dc2ecd8b449e1e16d0add5))
+
+- **deps**: Update all non-major dependencies
+  ([`d5de288`](https://github.com/python-gitlab/python-gitlab/commit/d5de28884f695a79e49605a698c4f17b868ddeb8))
+
+- **deps**: Update dependency types-setuptools to v70
+  ([`7767514`](https://github.com/python-gitlab/python-gitlab/commit/7767514a1ad4269a92a6610aa71aa8c595565a7d))
+
+- **deps**: Update gitlab/gitlab-ee docker tag to v17.0.1-ee.0
+  ([`df0ff4c`](https://github.com/python-gitlab/python-gitlab/commit/df0ff4c4c1497d6449488b8577ad7188b55c41a9))
+
+- **deps**: Update gitlab/gitlab-ee docker tag to v17.0.2-ee.0
+  ([`51779c6`](https://github.com/python-gitlab/python-gitlab/commit/51779c63e6a58e1ae68e9b1c3ffff998211d4e66))
+
+- **deps**: Update python-semantic-release/upload-to-gh-release digest to 477a404
+  ([`02a551d`](https://github.com/python-gitlab/python-gitlab/commit/02a551d82327b879b7a903b56b7962da552d1089))
+
+- **deps**: Update python-semantic-release/upload-to-gh-release digest to 6b7558f
+  ([`fd0f0b0`](https://github.com/python-gitlab/python-gitlab/commit/fd0f0b0338623a98e9368c30b600d603b966f8b7))
+
+### Features
+
+- Add `--no-mask-credentials` CLI argument
+  ([`18aa1fc`](https://github.com/python-gitlab/python-gitlab/commit/18aa1fc074b9f477cf0826933184bd594b63b489))
+
+This gives the ability to not mask credentials when using the `--debug` argument.
+
+- **api**: Add support for latest pipeline
+  ([`635f5a7`](https://github.com/python-gitlab/python-gitlab/commit/635f5a7128c780880824f69a9aba23af148dfeb4))
+
+
+## v4.6.0 (2024-05-28)
+
+### Bug Fixes
+
+- Don't raise `RedirectError` for redirected `HEAD` requests
+  ([`8fc13b9`](https://github.com/python-gitlab/python-gitlab/commit/8fc13b91d63d57c704d03b98920522a6469c96d7))
+
+- Handle large number of approval rules
+  ([`ef8f0e1`](https://github.com/python-gitlab/python-gitlab/commit/ef8f0e190b1add3bbba9a7b194aba2f3c1a83b2e))
+
+Use `iterator=True` when going through the list of current approval rules. This allows it to handle
+  more than the default of 20 approval rules.
+
+Closes: #2825
+
+- **cli**: Don't require `--id` when enabling a deploy key
+  ([`98fc578`](https://github.com/python-gitlab/python-gitlab/commit/98fc5789d39b81197351660b7a3f18903c2b91ba))
+
+No longer require `--id` when doing: gitlab project-key enable
+
+Now only the --project-id and --key-id are required.
+
+- **deps**: Update minimum dependency versions in pyproject.toml
+  ([`37b5a70`](https://github.com/python-gitlab/python-gitlab/commit/37b5a704ef6b94774e54110ba3746a950e733986))
+
+Update the minimum versions of the dependencies in the pyproject.toml file.
+
+This is related to PR #2878
+
+- **projects**: Fix 'import_project' file argument type for typings
+  ([`33fbc14`](https://github.com/python-gitlab/python-gitlab/commit/33fbc14ea8432df7e637462379e567f4d0ad6c18))
+
+Signed-off-by: Adrian DC <radian.dc@gmail.com>
+
+### Chores
+
+- Add an initial .git-blame-ignore-revs
+  ([`74db84c`](https://github.com/python-gitlab/python-gitlab/commit/74db84ca878ec7029643ff7b00db55f9ea085e9b))
+
+This adds the `.git-blame-ignore-revs` file which allows ignoring certain commits when doing a `git
+  blame --ignore-revs`
+
+Ignore the commit that requires keyword arguments for `register_custom_action()`
+
+https://docs.github.com/en/repositories/working-with-files/using-files/viewing-a-file#ignore-commits-in-the-blame-view
+
+- Add type info for ProjectFile.content
+  ([`62fa271`](https://github.com/python-gitlab/python-gitlab/commit/62fa2719ea129b3428e5e67d3d3a493f9aead863))
+
+Closes: #2821
+
+- Correct type-hint for `job.trace()`
+  ([`840572e`](https://github.com/python-gitlab/python-gitlab/commit/840572e4fa36581405b604a985d0e130fe43f4ce))
+
+Closes: #2808
+
+- Create a CustomAction dataclass
+  ([`61d8679`](https://github.com/python-gitlab/python-gitlab/commit/61d867925772cf38f20360c9b40140ac3203efb9))
+
+- Remove typing-extensions from requirements.txt
+  ([`d569128`](https://github.com/python-gitlab/python-gitlab/commit/d56912835360a1b5a03a20390fb45cb5e8b49ce4))
+
+We no longer support Python versions before 3.8. So it isn't needed anymore.
+
+- Require keyword arguments for register_custom_action
+  ([`7270523`](https://github.com/python-gitlab/python-gitlab/commit/7270523ad89a463c3542e072df73ba2255a49406))
+
+This makes it more obvious when reading the code what each argument is for.
+
+- Update commit reference in git-blame-ignore-revs
+  ([`d0fd5ad`](https://github.com/python-gitlab/python-gitlab/commit/d0fd5ad5a70e7eb70aedba5a0d3082418c5ffa34))
+
+- **cli**: Add ability to not add `_id_attr` as an argument
+  ([`2037352`](https://github.com/python-gitlab/python-gitlab/commit/20373525c1a1f98c18b953dbef896b2570d3d191))
+
+In some cases we don't want to have `_id_attr` as an argument.
+
+Add ability to have it not be added as an argument.
+
+- **cli**: Add some simple help for the standard operations
+  ([`5a4a940`](https://github.com/python-gitlab/python-gitlab/commit/5a4a940f42e43ed066838503638fe612813e504f))
+
+Add help for the following standard operations: * list: List the GitLab resources * get: Get a
+  GitLab resource * create: Create a GitLab resource * update: Update a GitLab resource * delete:
+  Delete a GitLab resource
+
+For example: $ gitlab project-key --help usage: gitlab project-key [-h]
+  {list,get,create,update,delete,enable} ...
+
+options: -h, --help show this help message and exit
+
+action: list get create update delete enable Action to execute on the GitLab resource. list List the
+  GitLab resources get Get a GitLab resource create Create a GitLab resource update Update a GitLab
+  resource delete Delete a GitLab resource
+
+- **cli**: On the CLI help show the API endpoint of resources
+  ([`f1ef565`](https://github.com/python-gitlab/python-gitlab/commit/f1ef5650c3201f3883eb04ad90a874e8adcbcde2))
+
+This makes it easier for people to map CLI command names to the API.
+
+Looks like this: $ gitlab --help <snip> The GitLab resource to manipulate. application API endpoint:
+  /applications application-appearance API endpoint: /application/appearance application-settings
+  API endpoint: /application/settings application-statistics API endpoint: /application/statistics
+  <snip>
+
+- **deps**: Update all non-major dependencies
+  ([`4c7014c`](https://github.com/python-gitlab/python-gitlab/commit/4c7014c13ed63f994e05b498d63b93dc8ab90c2e))
+
+- **deps**: Update all non-major dependencies
+  ([`ba1eec4`](https://github.com/python-gitlab/python-gitlab/commit/ba1eec49556ee022de471aae8d15060189f816e3))
+
+- **deps**: Update dependency requests to v2.32.0 [security]
+  ([`1bc788c`](https://github.com/python-gitlab/python-gitlab/commit/1bc788ca979a36eeff2e35241bdefc764cf335ce))
+
+- **deps**: Update gitlab/gitlab-ee docker tag to v17
+  ([`5070d07`](https://github.com/python-gitlab/python-gitlab/commit/5070d07d13b9c87588dbfde3750340e322118779))
+
+- **deps**: Update python-semantic-release/upload-to-gh-release digest to 673709c
+  ([`1b550ac`](https://github.com/python-gitlab/python-gitlab/commit/1b550ac706c8c31331a7a9dac607aed49f5e1fcf))
+
+### Features
+
+- More usernames support for MR approvals
+  ([`12d195a`](https://github.com/python-gitlab/python-gitlab/commit/12d195a35a1bd14947fbd6688a8ad1bd3fc21617))
+
+I don't think commit a2b8c8ccfb5d went far enough to enable usernames support. We create and edit a
+  lot of approval rules based on an external service (similar to CODE_OWNERS), but only have the
+  usernames available, and currently, have to look up each user to get their user ID to populate
+  user_ids for .set_approvers() calls. Would very much like to skip the lookup and just send the
+  usernames, which this change should allow.
+
+See: https://docs.gitlab.com/ee/api/merge_request_approvals.html#create-project-level-rule
+
+Signed-off-by: Jarod Wilson <jarod@redhat.com>
+
+- **api**: Add additional parameter to project/group iteration search
+  ([#2796](https://github.com/python-gitlab/python-gitlab/pull/2796),
+  [`623dac9`](https://github.com/python-gitlab/python-gitlab/commit/623dac9c8363c61dbf53f72af58835743e96656b))
+
+Co-authored-by: Cristiano Casella <cristiano.casella@seacom.it>
+
+Co-authored-by: Nejc Habjan <hab.nejc@gmail.com>
+
+- **api**: Add support for gitlab service account
+  ([#2851](https://github.com/python-gitlab/python-gitlab/pull/2851),
+  [`b187dea`](https://github.com/python-gitlab/python-gitlab/commit/b187deadabbfdf0326ecd79a3ee64c9de10c53e0))
+
+Co-authored-by: Nejc Habjan <hab.nejc@siemens.com>
+
+
+## v4.5.0 (2024-05-13)
+
+### Bug Fixes
+
+- Consider `scope` an ArrayAttribute in PipelineJobManager
+  ([`c5d0404`](https://github.com/python-gitlab/python-gitlab/commit/c5d0404ac9edfbfd328e7b4f07f554366377df3f))
+
+List query params like 'scope' were not being handled correctly for pipeline/jobs endpoint. This
+  change ensures multiple values are appended with '[]', resulting in the correct URL structure.
+
+Signed-off-by: Guilherme Gallo <guilherme.gallo@collabora.com>
+
+---
+
+Background: If one queries for pipeline jobs with `scope=["failed", "success"]`
+
+One gets: GET /api/v4/projects/176/pipelines/1113028/jobs?scope=success&scope=failed
+
+But it is supposed to get: GET
+  /api/v4/projects/176/pipelines/1113028/jobs?scope[]=success&scope[]=failed
+
+The current version only considers the last element of the list argument.
+
+- User.warn() to show correct filename of issue
+  ([`529f1fa`](https://github.com/python-gitlab/python-gitlab/commit/529f1faacee46a88cb0a542306309eb835516796))
+
+Previously would only go to the 2nd level of the stack for determining the offending filename and
+  line number. When it should be showing the first filename outside of the python-gitlab source
+  code. As we want it to show the warning for the user of the libraries code.
+
+Update test to show it works as expected.
+
+- **api**: Fix saving merge request approval rules
+  ([`b8b3849`](https://github.com/python-gitlab/python-gitlab/commit/b8b3849b2d4d3f2d9e81e5cf4f6b53368f7f0127))
+
+Closes #2548
+
+- **api**: Update manual job status when playing it
+  ([`9440a32`](https://github.com/python-gitlab/python-gitlab/commit/9440a3255018d6a6e49269caf4c878d80db508a8))
+
+- **cli**: Allow exclusive arguments as optional
+  ([#2770](https://github.com/python-gitlab/python-gitlab/pull/2770),
+  [`7ec3189`](https://github.com/python-gitlab/python-gitlab/commit/7ec3189d6eacdb55925e8be886a44d7ee09eb9ca))
+
+* fix(cli): allow exclusive arguments as optional
+
+The CLI takes its arguments from the RequiredOptional, which has three fields: required, optional,
+  and exclusive. In practice, the exclusive options are not defined as either required or optional,
+  and would not be allowed in the CLI. This changes that, so that exclusive options are also added
+  to the argument parser.
+
+* fix(cli): inform argument parser that options are mutually exclusive
+
+* fix(cli): use correct exclusive options, add unit test
+
+Closes #2769
+
+- **test**: Use different ids for merge request, approval rule, project
+  ([`c23e6bd`](https://github.com/python-gitlab/python-gitlab/commit/c23e6bd5785205f0f4b4c80321153658fc23fb98))
+
+The original bug was that the merge request identifier was used instead of the approval rule
+  identifier. The test didn't notice that because it used `1` for all identifiers. Make these
+  identifiers different so that a mixup will become apparent.
+
+### Build System
+
+- Add "--no-cache-dir" to pip commands in Dockerfile
+  ([`4ef94c8`](https://github.com/python-gitlab/python-gitlab/commit/4ef94c8260e958873bb626e86d3241daa22f7ce6))
+
+This would not leave cache files in the built docker image.
+
+Additionally, also only build the wheel in the build phase.
+
+On my machine, before this PR, size is 74845395; after this PR, size is 72617713.
+
+### Chores
+
+- Adapt style for black v24
+  ([`4e68d32`](https://github.com/python-gitlab/python-gitlab/commit/4e68d32c77ed587ab42d229d9f44c3bc40d1d0e5))
+
+- Add py312 & py313 to tox environment list
+  ([`679ddc7`](https://github.com/python-gitlab/python-gitlab/commit/679ddc7587d2add676fd2398cb9673bd1ca272e3))
+
+Even though there isn't a Python 3.13 at this time, this is done for the future. tox is already
+  configured to just warn about missing Python versions, but not fail if they don't exist.
+
+- Add tox `labels` to enable running groups of environments
+  ([`d7235c7`](https://github.com/python-gitlab/python-gitlab/commit/d7235c74f8605f4abfb11eb257246864c7dcf709))
+
+tox now has a feature of `labels` which allows running groups of environments using the command `tox
+  -m LABEL_NAME`. For example `tox -m lint` which has been setup to run the linters.
+
+Bumped the minimum required version of tox to be 4.0, which was released over a year ago.
+
+- Update `mypy` to 1.9.0 and resolve one issue
+  ([`dd00bfc`](https://github.com/python-gitlab/python-gitlab/commit/dd00bfc9c832aba0ed377573fe2e9120b296548d))
+
+mypy 1.9.0 flagged one issue in the code. Resolve the issue. Current unit tests already check that a
+  `None` value returns `text/plain`. So function is still working as expected.
+
+- Update version of `black` for `pre-commit`
+  ([`3501716`](https://github.com/python-gitlab/python-gitlab/commit/35017167a80809a49351f9e95916fafe61c7bfd5))
+
+The version of `black` needs to be updated to be in sync with what is in `requirements-lint.txt`
+
+- **deps**: Update all non-major dependencies
+  ([`4f338ae`](https://github.com/python-gitlab/python-gitlab/commit/4f338aed9c583a20ff5944e6ccbba5737c18b0f4))
+
+- **deps**: Update all non-major dependencies
+  ([`65d0e65`](https://github.com/python-gitlab/python-gitlab/commit/65d0e6520dcbcf5a708a87960c65fdcaf7e44bf3))
+
+- **deps**: Update all non-major dependencies
+  ([`1f0343c`](https://github.com/python-gitlab/python-gitlab/commit/1f0343c1154ca8ae5b1f61de1db2343a2ad652ec))
+
+- **deps**: Update all non-major dependencies
+  ([`0e9f4da`](https://github.com/python-gitlab/python-gitlab/commit/0e9f4da30cea507fcf83746008d9de2ee5a3bb9d))
+
+- **deps**: Update all non-major dependencies
+  ([`d5b5fb0`](https://github.com/python-gitlab/python-gitlab/commit/d5b5fb00d8947ed9733cbb5a273e2866aecf33bf))
+
+- **deps**: Update all non-major dependencies
+  ([`14a3ffe`](https://github.com/python-gitlab/python-gitlab/commit/14a3ffe4cc161be51a39c204350b5cd45c602335))
+
+- **deps**: Update all non-major dependencies
+  ([`3c4dcca`](https://github.com/python-gitlab/python-gitlab/commit/3c4dccaf51695334a5057b85d5ff4045739d1ad1))
+
+- **deps**: Update all non-major dependencies
+  ([`04c569a`](https://github.com/python-gitlab/python-gitlab/commit/04c569a2130d053e35c1f2520ef8bab09f2f9651))
+
+- **deps**: Update all non-major dependencies
+  ([`3c4b27e`](https://github.com/python-gitlab/python-gitlab/commit/3c4b27e64f4b51746b866f240a1291c2637355cc))
+
+- **deps**: Update all non-major dependencies
+  ([`7dc2fa6`](https://github.com/python-gitlab/python-gitlab/commit/7dc2fa6e632ed2c9adeb6ed32c4899ec155f6622))
+
+- **deps**: Update all non-major dependencies
+  ([`48726fd`](https://github.com/python-gitlab/python-gitlab/commit/48726fde9b3c2424310ff590b366b9fdefa4a146))
+
+- **deps**: Update codecov/codecov-action action to v4
+  ([`d2be1f7`](https://github.com/python-gitlab/python-gitlab/commit/d2be1f7608acadcc2682afd82d16d3706b7f7461))
+
+- **deps**: Update dependency black to v24
+  ([`f59aee3`](https://github.com/python-gitlab/python-gitlab/commit/f59aee3ddcfaeeb29fcfab4cc6768dff6b5558cb))
+
+- **deps**: Update dependency black to v24.3.0 [security]
+  ([`f6e8692`](https://github.com/python-gitlab/python-gitlab/commit/f6e8692cfc84b5af2eb6deec4ae1c4935b42e91c))
+
+- **deps**: Update dependency furo to v2024
+  ([`f6fd02d`](https://github.com/python-gitlab/python-gitlab/commit/f6fd02d956529e2c4bce261fe7b3da1442aaea12))
+
+- **deps**: Update dependency jinja2 to v3.1.4 [security]
+  ([`8ea10c3`](https://github.com/python-gitlab/python-gitlab/commit/8ea10c360175453c721ad8e27386e642c2b68d88))
+
+- **deps**: Update dependency myst-parser to v3
+  ([`9289189`](https://github.com/python-gitlab/python-gitlab/commit/92891890eb4730bc240213a212d392bcb869b800))
+
+- **deps**: Update dependency pytest to v8
+  ([`253babb`](https://github.com/python-gitlab/python-gitlab/commit/253babb9a7f8a7d469440fcfe1b2741ddcd8475e))
+
+- **deps**: Update dependency pytest-cov to v5
+  ([`db32000`](https://github.com/python-gitlab/python-gitlab/commit/db3200089ea83588ea7ad8bd5a7175d81f580630))
+
+- **deps**: Update dependency pytest-docker to v3
+  ([`35d2aec`](https://github.com/python-gitlab/python-gitlab/commit/35d2aec04532919d6dd7b7090bc4d5209eddd10d))
+
+- **deps**: Update gitlab/gitlab-ee docker tag to v16
+  ([`ea8c4c2`](https://github.com/python-gitlab/python-gitlab/commit/ea8c4c2bc9f17f510415a697e0fb19cabff4135e))
+
+- **deps**: Update gitlab/gitlab-ee docker tag to v16.11.1-ee.0
+  ([`1ed8d6c`](https://github.com/python-gitlab/python-gitlab/commit/1ed8d6c21d3463b2ad09eb553871042e98090ffd))
+
+- **deps**: Update gitlab/gitlab-ee docker tag to v16.11.2-ee.0
+  ([`9be48f0`](https://github.com/python-gitlab/python-gitlab/commit/9be48f0bcc2d32b5e8489f62f963389d5d54b2f2))
+
+- **deps**: Update python-semantic-release/python-semantic-release action to v9
+  ([`e11d889`](https://github.com/python-gitlab/python-gitlab/commit/e11d889cd19ec1555b2bbee15355a8cdfad61d5f))
+
+### Documentation
+
+- Add FAQ about conflicting parameters
+  ([`683ce72`](https://github.com/python-gitlab/python-gitlab/commit/683ce723352cc09e1a4b65db28be981ae6bb9f71))
+
+We have received multiple issues lately about this. Add it to the FAQ.
+
+- Correct rotate token example
+  ([`c53e695`](https://github.com/python-gitlab/python-gitlab/commit/c53e6954f097ed10d52b40660d2fba73c2e0e300))
+
+Rotate token returns a dict. Change example to print the entire dict.
+
+Closes: #2836
+
+- How to run smoke tests
+  ([`2d1f487`](https://github.com/python-gitlab/python-gitlab/commit/2d1f4872390df10174f865f7a935bc73f7865fec))
+
+Signed-off-by: Tim Knight <tim.knight1@engineering.digital.dwp.gov.uk>
+
+- Note how to use the Docker image from within GitLab CI
+  ([`6d4bffb`](https://github.com/python-gitlab/python-gitlab/commit/6d4bffb5aaa676d32fc892ef1ac002973bc040cb))
+
+Ref: #2823
+
+- **artifacts**: Fix argument indentation
+  ([`c631eeb`](https://github.com/python-gitlab/python-gitlab/commit/c631eeb55556920f5975b1fa2b1a0354478ce3c0))
+
+- **objects**: Minor rst formatting typo
+  ([`57dfd17`](https://github.com/python-gitlab/python-gitlab/commit/57dfd1769b4e22b43dc0936aa3600cd7e78ba289))
+
+To correctly format a code block have to use `::`
+
+- **README**: Tweak GitLab CI usage docs
+  ([`d9aaa99`](https://github.com/python-gitlab/python-gitlab/commit/d9aaa994568ad4896a1e8a0533ef0d1d2ba06bfa))
+
+### Features
+
+- **api**: Allow updating protected branches
+  ([#2771](https://github.com/python-gitlab/python-gitlab/pull/2771),
+  [`a867c48`](https://github.com/python-gitlab/python-gitlab/commit/a867c48baa6f10ffbfb785e624a6e3888a859571))
+
+* feat(api): allow updating protected branches
+
+Closes #2390
+
+- **cli**: Allow skipping initial auth calls
+  ([`001e596`](https://github.com/python-gitlab/python-gitlab/commit/001e59675f4a417a869f813d79c298a14268b87d))
+
+- **job_token_scope**: Support Groups in job token allowlist API
+  ([#2816](https://github.com/python-gitlab/python-gitlab/pull/2816),
+  [`2d1b749`](https://github.com/python-gitlab/python-gitlab/commit/2d1b7499a93db2c9600b383e166f7463a5f22085))
+
+* feat(job_token_scope): support job token access allowlist API
+
+Signed-off-by: Tim Knight <tim.knight1@engineering.digital.dwp.gov.uk>
+
+l.dwp.gov.uk> Co-authored-by: Nejc Habjan <nejc.habjan@siemens.com>
+
+### Testing
+
+- Don't use weak passwords
+  ([`c64d126`](https://github.com/python-gitlab/python-gitlab/commit/c64d126142cc77eae4297b8deec27bb1d68b7a13))
+
+Newer versions of GitLab will refuse to create a user with a weak password. In order for us to move
+  to a newer GitLab version in testing use a stronger password for the tests that create a user.
+
+- Remove approve step
+  ([`48a6705`](https://github.com/python-gitlab/python-gitlab/commit/48a6705558c5ab6fb08c62a18de350a5985099f8))
+
+Signed-off-by: Tim Knight <tim.knight1@engineering.digital.dwp.gov.uk>
+
+- Tidy up functional tests
+  ([`06266ea`](https://github.com/python-gitlab/python-gitlab/commit/06266ea5966c601c035ad8ce5840729e5f9baa57))
+
+Signed-off-by: Tim Knight <tim.knight1@engineering.digital.dwp.gov.uk>
+
+- Update api tests for GL 16.10
+  ([`4bef473`](https://github.com/python-gitlab/python-gitlab/commit/4bef47301342703f87c1ce1d2920d54f9927a66a))
+
+- Make sure we're testing python-gitlab functionality, make sure we're not awaiting on Gitlab Async
+  functions - Decouple and improve test stability
+
+Signed-off-by: Tim Knight <tim.knight1@engineering.digital.dwp.gov.uk>
+
+- Update tests for gitlab 16.8 functionality
+  ([`f8283ae`](https://github.com/python-gitlab/python-gitlab/commit/f8283ae69efd86448ae60d79dd8321af3f19ba1b))
+
+- use programmatic dates for expires_at in tokens tests - set PAT for 16.8 into tests
+
+Signed-off-by: Tim Knight <tim.knight1@engineering.digital.dwp.gov.uk>
+
+- **functional**: Enable bulk import feature flag before test
+  ([`b81da2e`](https://github.com/python-gitlab/python-gitlab/commit/b81da2e66ce385525730c089dbc2a5a85ba23287))
+
+- **smoke**: Normalize all dist titles for smoke tests
+  ([`ee013fe`](https://github.com/python-gitlab/python-gitlab/commit/ee013fe1579b001b4b30bae33404e827c7bdf8c1))
+
+
+## v4.4.0 (2024-01-15)
+
+### Bug Fixes
+
+- **cli**: Support binary files with `@` notation
+  ([`57749d4`](https://github.com/python-gitlab/python-gitlab/commit/57749d46de1d975aacb82758c268fc26e5e6ed8b))
+
+Support binary files being used in the CLI with arguments using the `@` notation. For example
+  `--avatar @/path/to/avatar.png`
+
+Also explicitly catch the common OSError exception, which is the parent exception for things like:
+  FileNotFoundError, PermissionError and more exceptions.
+
+Remove the bare exception handling. We would rather have the full traceback of any exceptions that
+  we don't know about and add them later if needed.
+
+Closes: #2752
+
+### Chores
+
+- **ci**: Add Python 3.13 development CI job
+  ([`ff0c11b`](https://github.com/python-gitlab/python-gitlab/commit/ff0c11b7b75677edd85f846a4dbdab08491a6bd7))
+
+Add a job to test the development versions of Python 3.13.
+
+- **ci**: Align upload and download action versions
+  ([`dcca59d`](https://github.com/python-gitlab/python-gitlab/commit/dcca59d1a5966283c1120cfb639c01a76214d2b2))
+
+- **deps**: Update actions/upload-artifact action to v4
+  ([`7114af3`](https://github.com/python-gitlab/python-gitlab/commit/7114af341dd12b7fb63ffc08650c455ead18ab70))
+
+- **deps**: Update all non-major dependencies
+  ([`550f935`](https://github.com/python-gitlab/python-gitlab/commit/550f9355d29a502bb022f68dab6c902bf6913552))
+
+- **deps**: Update all non-major dependencies
+  ([`cbc13a6`](https://github.com/python-gitlab/python-gitlab/commit/cbc13a61e0f15880b49a3d0208cc603d7d0b57e3))
+
+- **deps**: Update all non-major dependencies
+  ([`369a595`](https://github.com/python-gitlab/python-gitlab/commit/369a595a8763109a2af8a95a8e2423ebb30b9320))
+
+- **deps**: Update dependency flake8 to v7
+  ([`20243c5`](https://github.com/python-gitlab/python-gitlab/commit/20243c532a8a6d28eee0caff5b9c30cc7376a162))
+
+- **deps**: Update dependency jinja2 to v3.1.3 [security]
+  ([`880913b`](https://github.com/python-gitlab/python-gitlab/commit/880913b67cce711d96e89ce6813e305e4ba10908))
+
+- **deps**: Update pre-commit hook pycqa/flake8 to v7
+  ([`9a199b6`](https://github.com/python-gitlab/python-gitlab/commit/9a199b6089152e181e71a393925e0ec581bc55ca))
+
+### Features
+
+- **api**: Add reviewer_details manager for mergrequest to get reviewers of merge request
+  ([`adbd90c`](https://github.com/python-gitlab/python-gitlab/commit/adbd90cadffe1d9e9716a6e3826f30664866ad3f))
+
+Those changes implements 'GET /projects/:id/merge_requests/:merge_request_iid/reviewers' gitlab API
+  call. Naming for call is not reviewers because reviewers atribute already presen in merge request
+  response
+
+- **api**: Support access token rotate API
+  ([`b13971d`](https://github.com/python-gitlab/python-gitlab/commit/b13971d5472cb228f9e6a8f2fa05a7cc94d03ebe))
+
+- **api**: Support single resource access token get API
+  ([`dae9e52`](https://github.com/python-gitlab/python-gitlab/commit/dae9e522a26041f5b3c6461cc8a5e284f3376a79))
+
+
+## v4.3.0 (2023-12-28)
+
+### Bug Fixes
+
+- **cli**: Add ability to disable SSL verification
+  ([`3fe9fa6`](https://github.com/python-gitlab/python-gitlab/commit/3fe9fa64d9a38bc77950046f2950660d8d7e27a6))
+
+Add a `--no-ssl-verify` option to disable SSL verification
+
+Closes: #2714
+
+### Chores
+
+- **deps**: Update actions/setup-python action to v5
+  ([`fad1441`](https://github.com/python-gitlab/python-gitlab/commit/fad14413f4f27f1b6f902703b5075528aac52451))
+
+- **deps**: Update actions/stale action to v9
+  ([`c01988b`](https://github.com/python-gitlab/python-gitlab/commit/c01988b12c7745929d0c591f2fa265df2929a859))
+
+- **deps**: Update all non-major dependencies
+  ([`d7bdb02`](https://github.com/python-gitlab/python-gitlab/commit/d7bdb0257a5587455c3722f65c4a632f24d395be))
+
+- **deps**: Update all non-major dependencies
+  ([`9e067e5`](https://github.com/python-gitlab/python-gitlab/commit/9e067e5c67dcf9f5e6c3408b30d9e2525c768e0a))
+
+- **deps**: Update all non-major dependencies
+  ([`bb2af7b`](https://github.com/python-gitlab/python-gitlab/commit/bb2af7bfe8aa59ea8b9ad7ca2d6e56f4897b704a))
+
+- **deps**: Update all non-major dependencies
+  ([`5ef1b4a`](https://github.com/python-gitlab/python-gitlab/commit/5ef1b4a6c8edd34c381c6e08cd3893ef6c0685fd))
+
+- **deps**: Update dependency types-setuptools to v69
+  ([`de11192`](https://github.com/python-gitlab/python-gitlab/commit/de11192455f1c801269ecb3bdcbc7c5b769ff354))
+
+### Documentation
+
+- Fix rst link typo in CONTRIBUTING.rst
+  ([`2b6da6e`](https://github.com/python-gitlab/python-gitlab/commit/2b6da6e63c82a61b8e21d193cfd46baa3fcf8937))
+
+### Features
+
+- **api**: Add support for the Draft notes API
+  ([#2728](https://github.com/python-gitlab/python-gitlab/pull/2728),
+  [`ebf9d82`](https://github.com/python-gitlab/python-gitlab/commit/ebf9d821cfc36071fca05d38b82c641ae30c974c))
+
+* feat(api): add support for the Draft notes API
+
+* fix(client): handle empty 204 reponses in PUT requests
+
+
+## v4.2.0 (2023-11-28)
+
+### Chores
+
+- **deps**: Update all non-major dependencies
+  ([`8aeb853`](https://github.com/python-gitlab/python-gitlab/commit/8aeb8531ebd3ddf0d1da3fd74597356ef65c00b3))
+
+- **deps**: Update all non-major dependencies
+  ([`9fe2335`](https://github.com/python-gitlab/python-gitlab/commit/9fe2335b9074feaabdb683b078ff8e12edb3959e))
+
+- **deps**: Update all non-major dependencies
+  ([`91e66e9`](https://github.com/python-gitlab/python-gitlab/commit/91e66e9b65721fa0e890a6664178d77ddff4272a))
+
+- **deps**: Update all non-major dependencies
+  ([`d0546e0`](https://github.com/python-gitlab/python-gitlab/commit/d0546e043dfeb988a161475de53d4ec7d756bdd9))
+
+- **deps**: Update dessant/lock-threads action to v5
+  ([`f4ce867`](https://github.com/python-gitlab/python-gitlab/commit/f4ce86770befef77c7c556fd5cfe25165f59f515))
+
+### Features
+
+- Add pipeline status as Enum
+  ([`4954bbc`](https://github.com/python-gitlab/python-gitlab/commit/4954bbcd7e8433aac672405f3f4741490cb4561a))
+
+https://docs.gitlab.com/ee/api/pipelines.html
+
+- **api**: Add support for wiki attachments
+  ([#2722](https://github.com/python-gitlab/python-gitlab/pull/2722),
+  [`7b864b8`](https://github.com/python-gitlab/python-gitlab/commit/7b864b81fd348c6a42e32ace846d1acbcfc43998))
+
+Added UploadMixin in mixin module Added UploadMixin dependency for Project, ProjectWiki, GroupWiki
+  Added api tests for wiki upload Added unit test for mixin Added docs sections to wikis.rst
+
+
+## v4.1.1 (2023-11-03)
+
+### Bug Fixes
+
+- **build**: Include py.typed in dists
+  ([`b928639`](https://github.com/python-gitlab/python-gitlab/commit/b928639f7ca252e0abb8ded8f9f142316a4dc823))
+
+### Chores
+
+- **ci**: Add release id to workflow step
+  ([`9270e10`](https://github.com/python-gitlab/python-gitlab/commit/9270e10d94101117bec300c756889e4706f41f36))
+
+- **deps**: Update all non-major dependencies
+  ([`32954fb`](https://github.com/python-gitlab/python-gitlab/commit/32954fb95dcc000100b48c4b0b137ebe2eca85a3))
+
+### Documentation
+
+- **users**: Add missing comma in v4 API create runner examples
+  ([`b1b2edf`](https://github.com/python-gitlab/python-gitlab/commit/b1b2edfa05be8b957c796dc6d111f40c9f753dcf))
+
+The examples which show usage of new runner registration api endpoint are missing commas. This
+  change adds the missing commas.
+
+
+## v4.1.0 (2023-10-28)
+
+### Bug Fixes
+
+- Remove depricated MergeStatus
+  ([`c6c012b`](https://github.com/python-gitlab/python-gitlab/commit/c6c012b9834b69f1fe45689519fbcd92928cfbad))
+
+### Chores
+
+- Add source label to container image
+  ([`7b19278`](https://github.com/python-gitlab/python-gitlab/commit/7b19278ac6b7a106bc518f264934c7878ffa49fb))
+
+- **CHANGELOG**: Re-add v4.0.0 changes using old format
+  ([`258a751`](https://github.com/python-gitlab/python-gitlab/commit/258a751049c8860e39097b26d852d1d889892d7a))
+
+- **CHANGELOG**: Revert python-semantic-release format change
+  ([`b5517e0`](https://github.com/python-gitlab/python-gitlab/commit/b5517e07da5109b1a43db876507d8000d87070fe))
+
+- **deps**: Update all non-major dependencies
+  ([`bf68485`](https://github.com/python-gitlab/python-gitlab/commit/bf68485613756e9916de1bb10c8c4096af4ffd1e))
+
+- **rtd**: Revert to python 3.11 ([#2694](https://github.com/python-gitlab/python-gitlab/pull/2694),
+  [`1113742`](https://github.com/python-gitlab/python-gitlab/commit/1113742d55ea27da121853130275d4d4de45fd8f))
+
+### Continuous Integration
+
+- Remove unneeded GitLab auth
+  ([`fd7bbfc`](https://github.com/python-gitlab/python-gitlab/commit/fd7bbfcb9500131e5d3a263d7b97c8b59f80b7e2))
+
+### Features
+
+- Add Merge Request merge_status and detailed_merge_status values as constants
+  ([`e18a424`](https://github.com/python-gitlab/python-gitlab/commit/e18a4248068116bdcb7af89897a0c4c500f7ba57))
+
+
+## v4.0.0 (2023-10-17)
+
+### Bug Fixes
+
+- **cli**: Add _from_parent_attrs to user-project manager
+  ([#2558](https://github.com/python-gitlab/python-gitlab/pull/2558),
+  [`016d90c`](https://github.com/python-gitlab/python-gitlab/commit/016d90c3c22bfe6fc4e866d120d2c849764ef9d2))
+
+- **cli**: Fix action display in --help when there are few actions
+  ([`b22d662`](https://github.com/python-gitlab/python-gitlab/commit/b22d662a4fd8fb8a9726760b645d4da6197bfa9a))
+
+fixes #2656
+
+- **cli**: Remove deprecated `--all` option in favor of `--get-all`
+  ([`e9d48cf`](https://github.com/python-gitlab/python-gitlab/commit/e9d48cf69e0dbe93f917e6f593d31327cd99f917))
+
+BREAKING CHANGE: The `--all` option is no longer available in the CLI. Use `--get-all` instead.
+
+- **client**: Support empty 204 responses in http_patch
+  ([`e15349c`](https://github.com/python-gitlab/python-gitlab/commit/e15349c9a796f2d82f72efbca289740016c47716))
+
+- **snippets**: Allow passing list of files
+  ([`31c3c5e`](https://github.com/python-gitlab/python-gitlab/commit/31c3c5ea7cbafb4479825ec40bc34e3b8cb427fd))
+
+### Chores
+
+- Add package pipelines API link
+  ([`2a2404f`](https://github.com/python-gitlab/python-gitlab/commit/2a2404fecdff3483a68f538c8cd6ba4d4fc6538c))
+
+- Change `_update_uses` to `_update_method` and use an Enum
+  ([`7073a2d`](https://github.com/python-gitlab/python-gitlab/commit/7073a2dfa3a4485d2d3a073d40122adbeff42b5c))
+
+Change the name of the `_update_uses` attribute to `_update_method` and store an Enum in the
+  attribute to indicate which type of HTTP method to use. At the moment it supports `POST` and
+  `PUT`. But can in the future support `PATCH`.
+
+- Fix test names
+  ([`f1654b8`](https://github.com/python-gitlab/python-gitlab/commit/f1654b8065a7c8349777780e673aeb45696fccd0))
+
+- Make linters happy
+  ([`3b83d5d`](https://github.com/python-gitlab/python-gitlab/commit/3b83d5d13d136f9a45225929a0c2031dc28cdbed))
+
+- Switch to docker-compose v2
+  ([`713b5ca`](https://github.com/python-gitlab/python-gitlab/commit/713b5ca272f56b0fd7340ca36746e9649a416aa2))
+
+Closes: #2625
+
+- Update PyYAML to 6.0.1
+  ([`3b8939d`](https://github.com/python-gitlab/python-gitlab/commit/3b8939d7669f391a5a7e36d623f8ad6303ba7712))
+
+Fixes issue with CI having error: `AttributeError: cython_sources`
+
+Closes: #2624
+
+- **ci**: Adapt release workflow and config for v8
+  ([`827fefe`](https://github.com/python-gitlab/python-gitlab/commit/827fefeeb7bf00e5d8fa142d7686ead97ca4b763))
+
+- **ci**: Fix pre-commit deps and python version
+  ([`1e7f257`](https://github.com/python-gitlab/python-gitlab/commit/1e7f257e79a7adf1e6f2bc9222fd5031340d26c3))
+
+- **ci**: Follow upstream config for release build_command
+  ([`3e20a76`](https://github.com/python-gitlab/python-gitlab/commit/3e20a76fdfc078a03190939bda303577b2ef8614))
+
+- **ci**: Remove Python 3.13 dev job
+  ([`e8c50f2`](https://github.com/python-gitlab/python-gitlab/commit/e8c50f28da7e3879f0dc198533041348a14ddc68))
+
+- **ci**: Update release build for python-semantic-release v8
+  ([#2692](https://github.com/python-gitlab/python-gitlab/pull/2692),
+  [`bf050d1`](https://github.com/python-gitlab/python-gitlab/commit/bf050d19508978cbaf3e89d49f42162273ac2241))
+
+- **deps**: Bring furo up to date with sphinx
+  ([`a15c927`](https://github.com/python-gitlab/python-gitlab/commit/a15c92736f0cf78daf78f77fb318acc6c19036a0))
+
+- **deps**: Bring myst-parser up to date with sphinx 7
+  ([`da03e9c`](https://github.com/python-gitlab/python-gitlab/commit/da03e9c7dc1c51978e51fedfc693f0bce61ddaf1))
+
+- **deps**: Pin pytest-console-scripts for 3.7
+  ([`6d06630`](https://github.com/python-gitlab/python-gitlab/commit/6d06630cac1a601bc9a17704f55dcdc228285e88))
+
+- **deps**: Update actions/checkout action to v3
+  ([`e2af1e8`](https://github.com/python-gitlab/python-gitlab/commit/e2af1e8a964fe8603dddef90a6df62155f25510d))
+
+- **deps**: Update actions/checkout action to v4
+  ([`af13914`](https://github.com/python-gitlab/python-gitlab/commit/af13914e41f60cc2c4ef167afb8f1a10095e8a00))
+
+- **deps**: Update actions/setup-python action to v4
+  ([`e0d6783`](https://github.com/python-gitlab/python-gitlab/commit/e0d6783026784bf1e6590136da3b35051e7edbb3))
+
+- **deps**: Update actions/upload-artifact action to v3
+  ([`b78d6bf`](https://github.com/python-gitlab/python-gitlab/commit/b78d6bfd18630fa038f5f5bd8e473ec980495b10))
+
+- **deps**: Update all non-major dependencies
+  ([`1348a04`](https://github.com/python-gitlab/python-gitlab/commit/1348a040207fc30149c664ac0776e698ceebe7bc))
+
+- **deps**: Update all non-major dependencies
+  ([`ff45124`](https://github.com/python-gitlab/python-gitlab/commit/ff45124e657c4ac4ec843a13be534153a8b10a20))
+
+- **deps**: Update all non-major dependencies
+  ([`0d49164`](https://github.com/python-gitlab/python-gitlab/commit/0d491648d16f52f5091b23d0e3e5be2794461ade))
+
+- **deps**: Update all non-major dependencies
+  ([`6093dbc`](https://github.com/python-gitlab/python-gitlab/commit/6093dbcf07b9edf35379142ea58a190050cf7fe7))
+
+- **deps**: Update all non-major dependencies
+  ([`bb728b1`](https://github.com/python-gitlab/python-gitlab/commit/bb728b1c259dba5699467c9ec7a51b298a9e112e))
+
+- **deps**: Update all non-major dependencies
+  ([`9083787`](https://github.com/python-gitlab/python-gitlab/commit/9083787f0855d94803c633b0491db70f39a9867a))
+
+- **deps**: Update all non-major dependencies
+  ([`b6a3db1`](https://github.com/python-gitlab/python-gitlab/commit/b6a3db1a2b465a34842d1a544a5da7eee6430708))
+
+- **deps**: Update all non-major dependencies
+  ([`16f2d34`](https://github.com/python-gitlab/python-gitlab/commit/16f2d3428e673742a035856b1fb741502287cc1d))
+
+- **deps**: Update all non-major dependencies
+  ([`5b33ade`](https://github.com/python-gitlab/python-gitlab/commit/5b33ade92152e8ccb9db3eb369b003a688447cd6))
+
+- **deps**: Update all non-major dependencies
+  ([`3732841`](https://github.com/python-gitlab/python-gitlab/commit/37328416d87f50f64c9bdbdcb49e9b9a96d2d0ef))
+
+- **deps**: Update all non-major dependencies
+  ([`511f45c`](https://github.com/python-gitlab/python-gitlab/commit/511f45cda08d457263f1011b0d2e013e9f83babc))
+
+- **deps**: Update all non-major dependencies
+  ([`d4a7410`](https://github.com/python-gitlab/python-gitlab/commit/d4a7410e55c6a98a15f4d7315cc3d4fde0190bce))
+
+- **deps**: Update all non-major dependencies
+  ([`12846cf`](https://github.com/python-gitlab/python-gitlab/commit/12846cfe4a0763996297bb0a43aa958fe060f029))
+
+- **deps**: Update all non-major dependencies
+  ([`33d2aa2`](https://github.com/python-gitlab/python-gitlab/commit/33d2aa21035515711738ac192d8be51fd6106863))
+
+- **deps**: Update all non-major dependencies
+  ([`5ff56d8`](https://github.com/python-gitlab/python-gitlab/commit/5ff56d866c6fdac524507628cf8baf2c498347af))
+
+- **deps**: Update all non-major dependencies
+  ([`7586a5c`](https://github.com/python-gitlab/python-gitlab/commit/7586a5c80847caf19b16282feb25be470815729b))
+
+- **deps**: Update all non-major dependencies to v23.9.1
+  ([`a16b732`](https://github.com/python-gitlab/python-gitlab/commit/a16b73297a3372ce4f3ada3b4ea99680dbd511f6))
+
+- **deps**: Update dependency build to v1
+  ([`2e856f2`](https://github.com/python-gitlab/python-gitlab/commit/2e856f24567784ddc35ca6895d11bcca78b58ca4))
+
+- **deps**: Update dependency commitizen to v3.10.0
+  ([`becd8e2`](https://github.com/python-gitlab/python-gitlab/commit/becd8e20eb66ce4e606f22c15abf734a712c20c3))
+
+- **deps**: Update dependency pylint to v3
+  ([`491350c`](https://github.com/python-gitlab/python-gitlab/commit/491350c40a74bbb4945dfb9f2618bcc5420a4603))
+
+- **deps**: Update dependency pytest-docker to v2
+  ([`b87bb0d`](https://github.com/python-gitlab/python-gitlab/commit/b87bb0db1441d1345048664b15bd8122e6b95be4))
+
+- **deps**: Update dependency setuptools to v68
+  ([`0f06082`](https://github.com/python-gitlab/python-gitlab/commit/0f06082272f7dbcfd79f895de014cafed3205ff6))
+
+- **deps**: Update dependency sphinx to v7
+  ([`2918dfd`](https://github.com/python-gitlab/python-gitlab/commit/2918dfd78f562e956c5c53b79f437a381e51ebb7))
+
+- **deps**: Update dependency types-setuptools to v68
+  ([`bdd4eb6`](https://github.com/python-gitlab/python-gitlab/commit/bdd4eb694f8b56d15d33956cb982a71277ca907f))
+
+- **deps**: Update dependency ubuntu to v22
+  ([`8865552`](https://github.com/python-gitlab/python-gitlab/commit/88655524ac2053f5b7016457f8c9d06a4b888660))
+
+- **deps**: Update pre-commit hook commitizen-tools/commitizen to v3.10.0
+  ([`626c2f8`](https://github.com/python-gitlab/python-gitlab/commit/626c2f8879691e5dd4ce43118668e6a88bf6f7ad))
+
+- **deps**: Update pre-commit hook maxbrunet/pre-commit-renovate to v36
+  ([`db58cca`](https://github.com/python-gitlab/python-gitlab/commit/db58cca2e2b7d739b069904cb03f42c9bc1d3810))
+
+- **deps**: Update pre-commit hook maxbrunet/pre-commit-renovate to v37
+  ([`b4951cd`](https://github.com/python-gitlab/python-gitlab/commit/b4951cd273d599e6d93b251654808c6eded2a960))
+
+- **deps**: Update pre-commit hook pycqa/pylint to v3
+  ([`0f4a346`](https://github.com/python-gitlab/python-gitlab/commit/0f4a34606f4df643a5dbae1900903bcf1d47b740))
+
+- **deps**: Update relekang/python-semantic-release action to v8
+  ([`c57c85d`](https://github.com/python-gitlab/python-gitlab/commit/c57c85d0fc6543ab5a2322fc58ec1854afc4f54f))
+
+- **helpers**: Fix previously undetected flake8 issue
+  ([`bf8bd73`](https://github.com/python-gitlab/python-gitlab/commit/bf8bd73e847603e8ac5d70606f9393008eee1683))
+
+- **rtd**: Fix docs build on readthedocs.io
+  ([#2654](https://github.com/python-gitlab/python-gitlab/pull/2654),
+  [`3d7139b`](https://github.com/python-gitlab/python-gitlab/commit/3d7139b64853cb0da46d0ef6a4bccc0175f616c2))
+
+- **rtd**: Use readthedocs v2 syntax
+  ([`6ce2149`](https://github.com/python-gitlab/python-gitlab/commit/6ce214965685a3e73c02e9b93446ad8d9a29262e))
+
+### Documentation
+
+- Correct error with back-ticks ([#2653](https://github.com/python-gitlab/python-gitlab/pull/2653),
+  [`0b98dd3`](https://github.com/python-gitlab/python-gitlab/commit/0b98dd3e92179652806a7ae8ccc7ec5cddd2b260))
+
+New linting package update detected the issue.
+
+- **access_token**: Adopt token docs to 16.1
+  ([`fe7a971`](https://github.com/python-gitlab/python-gitlab/commit/fe7a971ad3ea1e66ffc778936296e53825c69f8f))
+
+expires_at is now required Upstream MR: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/124964
+
+- **advanced**: Document new netrc behavior
+  ([`45b8930`](https://github.com/python-gitlab/python-gitlab/commit/45b89304d9745be1b87449805bf53d45bf740e90))
+
+BREAKING CHANGE: python-gitlab now explicitly passes auth to requests, meaning it will only read
+  netrc credentials if no token is provided, fixing a bug where netrc credentials took precedence
+  over OAuth tokens. This also affects the CLI, where all environment variables now take precedence
+  over netrc files.
+
+- **files**: Fix minor typo in variable declaration
+  ([`118ce42`](https://github.com/python-gitlab/python-gitlab/commit/118ce4282abc4397c4e9370407b1ab6866de9f97))
+
+### Features
+
+- Added iteration to issue and group filters
+  ([`8d2d297`](https://github.com/python-gitlab/python-gitlab/commit/8d2d2971c3909fb5461a9f7b2d07508866cd456c))
+
+- Officially support Python 3.12
+  ([`2a69c0e`](https://github.com/python-gitlab/python-gitlab/commit/2a69c0ee0a86315a3ed4750f59bd6ab3e4199b8e))
+
+- Remove support for Python 3.7, require 3.8 or higher
+  ([`058d5a5`](https://github.com/python-gitlab/python-gitlab/commit/058d5a56c284c771f1fb5fad67d4ef2eeb4d1916))
+
+Python 3.8 is End-of-Life (EOL) as of 2023-06-27 as stated in https://devguide.python.org/versions/
+  and https://peps.python.org/pep-0537/
+
+By dropping support for Python 3.7 and requiring Python 3.8 or higher it allows python-gitlab to
+  take advantage of new features in Python 3.8, which are documented at:
+  https://docs.python.org/3/whatsnew/3.8.html
+
+BREAKING CHANGE: As of python-gitlab 4.0.0, Python 3.7 is no longer supported. Python 3.8 or higher
+  is required.
+
+- Use requests AuthBase classes
+  ([`5f46cfd`](https://github.com/python-gitlab/python-gitlab/commit/5f46cfd235dbbcf80678e45ad39a2c3b32ca2e39))
+
+- **api**: Add optional GET attrs for /projects/:id/ci/lint
+  ([`40a102d`](https://github.com/python-gitlab/python-gitlab/commit/40a102d4f5c8ff89fae56cd9b7c8030c5070112c))
+
+- **api**: Add ProjectPackagePipeline
+  ([`5b4addd`](https://github.com/python-gitlab/python-gitlab/commit/5b4addda59597a5f363974e59e5ea8463a0806ae))
+
+Add ProjectPackagePipeline, which is scheduled to be included in GitLab 16.0
+
+- **api**: Add support for job token scope settings
+  ([`59d6a88`](https://github.com/python-gitlab/python-gitlab/commit/59d6a880aacd7cf6f443227071bb8288efb958c4))
+
+- **api**: Add support for new runner creation API
+  ([#2635](https://github.com/python-gitlab/python-gitlab/pull/2635),
+  [`4abcd17`](https://github.com/python-gitlab/python-gitlab/commit/4abcd1719066edf9ecc249f2da4a16c809d7b181))
+
+Co-authored-by: Nejc Habjan <hab.nejc@gmail.com>
+
+- **api**: Support project remote mirror deletion
+  ([`d900910`](https://github.com/python-gitlab/python-gitlab/commit/d9009100ec762c307b46372243d93f9bc2de7a2b))
+
+- **client**: Mask tokens by default when logging
+  ([`1611d78`](https://github.com/python-gitlab/python-gitlab/commit/1611d78263284508326347843f634d2ca8b41215))
+
+- **packages**: Allow uploading bytes and files
+  ([`61e0fae`](https://github.com/python-gitlab/python-gitlab/commit/61e0faec2014919e0a2e79106089f6838be8ad0e))
+
+This commit adds a keyword argument to GenericPackageManager.upload() to allow uploading bytes and
+  file-like objects to the generic package registry. That necessitates changing file path to be a
+  keyword argument as well, which then cascades into a whole slew of checks to not allow passing
+  both and to not allow uploading file-like objects as JSON data.
+
+Closes https://github.com/python-gitlab/python-gitlab/issues/1815
+
+- **releases**: Add support for direct_asset_path
+  ([`d054917`](https://github.com/python-gitlab/python-gitlab/commit/d054917ccb3bbcc9973914409b9e34ba9301663a))
+
+This commit adds support for the “new” alias for `filepath`: `direct_asset_path` (added in 15.10) in
+  release links API.
+
+### Refactoring
+
+- **artifacts**: Remove deprecated `artifact()`in favor of `artifacts.raw()`
+  ([`90134c9`](https://github.com/python-gitlab/python-gitlab/commit/90134c949b38c905f9cacf3b4202c25dec0282f3))
+
+BREAKING CHANGE: The deprecated `project.artifact()` method is no longer available. Use
+  `project.artifacts.raw()` instead.
+
+- **artifacts**: Remove deprecated `artifacts()`in favor of `artifacts.download()`
+  ([`42639f3`](https://github.com/python-gitlab/python-gitlab/commit/42639f3ec88f3a3be32e36b97af55240e98c1d9a))
+
+BREAKING CHANGE: The deprecated `project.artifacts()` method is no longer available. Use
+  `project.artifacts.download()` instead.
+
+- **build**: Build project using PEP 621
+  ([`71fca8c`](https://github.com/python-gitlab/python-gitlab/commit/71fca8c8f5c7f3d6ab06dd4e6c0d91003705be09))
+
+BREAKING CHANGE: python-gitlab now stores metadata in pyproject.toml as per PEP 621, with setup.py
+  removed. pip version v21.1 or higher is required if you want to perform an editable install.
+
+- **const**: Remove deprecated global constant import
+  ([`e4a1f6e`](https://github.com/python-gitlab/python-gitlab/commit/e4a1f6e2d1c4e505f38f9fd948d0fea9520aa909))
+
+BREAKING CHANGE: Constants defined in `gitlab.const` can no longer be imported globally from
+  `gitlab`. Import them from `gitlab.const` instead.
+
+- **groups**: Remove deprecated LDAP group link add/delete methods
+  ([`5c8b7c1`](https://github.com/python-gitlab/python-gitlab/commit/5c8b7c1369a28d75261002e7cb6d804f7d5658c6))
+
+BREAKING CHANGE: The deprecated `group.add_ldap_group_link()` and `group.delete_ldap_group_link()`
+  methods are no longer available. Use `group.ldap_group_links.create()` and
+  `group.ldap_group_links.delete()` instead.
+
+- **lint**: Remove deprecated `lint()`in favor of `ci_lint.create()`
+  ([`0b17a2d`](https://github.com/python-gitlab/python-gitlab/commit/0b17a2d24a3f9463dfbcab6b4fddfba2aced350b))
+
+BREAKING CHANGE: The deprecated `lint()` method is no longer available. Use `ci_lint.create()`
+  instead.
+
+- **list**: `as_list` support is removed.
+  ([`9b6d89e`](https://github.com/python-gitlab/python-gitlab/commit/9b6d89edad07979518a399229c6f55bffeb9af08))
+
+In `list()` calls support for the `as_list` argument has been removed. `as_list` was previously
+  deprecated and now the use of `iterator` will be required if wanting to have same functionality as
+  using `as_list`
+
+BREAKING CHANGE: Support for the deprecated `as_list` argument in `list()` calls has been removed.
+  Use `iterator` instead.
+
+- **projects**: Remove deprecated `project.transfer_project()` in favor of `project.transfer()`
+  ([`27ed490`](https://github.com/python-gitlab/python-gitlab/commit/27ed490c22008eef383e1a346ad0c721cdcc6198))
+
+BREAKING CHANGE: The deprecated `project.transfer_project()` method is no longer available. Use
+  `project.transfer()` instead.
+
+### Testing
+
+- Add tests for token masking
+  ([`163bfcf`](https://github.com/python-gitlab/python-gitlab/commit/163bfcf6c2c1ccc4710c91e6f75b51e630dfb719))
+
+- Correct calls to `script_runner.run()`
+  ([`cd04315`](https://github.com/python-gitlab/python-gitlab/commit/cd04315de86aca2bb471865b2754bb66e96f0119))
+
+Warnings were being raised. Resolve those warnings.
+
+- Fix failing tests that use 204 (No Content) plus content
+  ([`3074f52`](https://github.com/python-gitlab/python-gitlab/commit/3074f522551b016451aa968f22a3dc5715db281b))
+
+urllib3>=2 now checks for expected content length. Also codes 204 and 304 are set to expect a
+  content length of 0 [1]
+
+So in the unit tests stop setting content to return in these situations.
+
+[1]
+  https://github.com/urllib3/urllib3/blob/88a707290b655394aade060a8b7eaee83152dc8b/src/urllib3/response.py#L691-L693
+
+- **cli**: Add test for user-project list
+  ([`a788cff`](https://github.com/python-gitlab/python-gitlab/commit/a788cff7c1c651c512f15a9a1045c1e4d449d854))
+
+### BREAKING CHANGES
+
+- **advanced**: Python-gitlab now explicitly passes auth to requests, meaning it will only read
+  netrc credentials if no token is provided, fixing a bug where netrc credentials took precedence
+  over OAuth tokens. This also affects the CLI, where all environment variables now take precedence
+  over netrc files.
+
+- **build**: Python-gitlab now stores metadata in pyproject.toml as per PEP 621, with setup.py
+  removed. pip version v21.1 or higher is required if you want to perform an editable install.
+
+
+## v3.15.0 (2023-06-09)
+
+### Chores
+
+- Update copyright year to include 2023
+  ([`511c6e5`](https://github.com/python-gitlab/python-gitlab/commit/511c6e507e4161531732ce4c323aeb4481504b08))
+
+- Update sphinx from 5.3.0 to 6.2.1
+  ([`c44a290`](https://github.com/python-gitlab/python-gitlab/commit/c44a29016b13e535621e71ec4f5392b4c9a93552))
+
+- **ci**: Use OIDC trusted publishing for pypi.org
+  ([#2559](https://github.com/python-gitlab/python-gitlab/pull/2559),
+  [`7be09e5`](https://github.com/python-gitlab/python-gitlab/commit/7be09e52d75ed8ab723d7a65f5e99d98fe6f52b0))
+
+* chore(ci): use OIDC trusted publishing for pypi.org
+
+* chore(ci): explicitly install setuptools in tests
+
+- **deps**: Update all non-major dependencies
+  ([`e3de6ba`](https://github.com/python-gitlab/python-gitlab/commit/e3de6bac98edd8a4cb87229e639212b9fb1500f9))
+
+- **deps**: Update dependency commitizen to v3
+  ([`784d59e`](https://github.com/python-gitlab/python-gitlab/commit/784d59ef46703c9afc0b1e390f8c4194ee10bb0a))
+
+- **deps**: Update dependency myst-parser to v1
+  ([`9c39848`](https://github.com/python-gitlab/python-gitlab/commit/9c3984896c243ad082469ae69342e09d65b5b5ef))
+
+- **deps**: Update dependency requests-toolbelt to v1
+  ([`86eba06`](https://github.com/python-gitlab/python-gitlab/commit/86eba06736b7610d8c4e77cd96ae6071c40067d5))
+
+- **deps**: Update dependency types-setuptools to v67
+  ([`c562424`](https://github.com/python-gitlab/python-gitlab/commit/c56242413e0eb36e41981f577162be8b69e53b67))
+
+- **deps**: Update pre-commit hook commitizen-tools/commitizen to v3
+  ([`1591e33`](https://github.com/python-gitlab/python-gitlab/commit/1591e33f0b315c7eb544dc98a6567c33c2ac143f))
+
+- **deps**: Update pre-commit hook maxbrunet/pre-commit-renovate to v35
+  ([`8202e3f`](https://github.com/python-gitlab/python-gitlab/commit/8202e3fe01b34da3ff29a7f4189d80a2153f08a4))
+
+### Documentation
+
+- Remove exclusive EE about issue links
+  ([`e0f6f18`](https://github.com/python-gitlab/python-gitlab/commit/e0f6f18f14c8c17ea038a7741063853c105e7fa3))
+
+### Features
+
+- Add support for `select="package_file"` in package upload
+  ([`3a49f09`](https://github.com/python-gitlab/python-gitlab/commit/3a49f099d54000089e217b61ffcf60b6a28b4420))
+
+Add ability to use `select="package_file"` when uploading a generic package as described in:
+  https://docs.gitlab.com/ee/user/packages/generic_packages/index.html
+
+Closes: #2557
+
+- Usernames support for MR approvals
+  ([`a2b8c8c`](https://github.com/python-gitlab/python-gitlab/commit/a2b8c8ccfb5d4fa4d134300861a3bfb0b10246ca))
+
+This can be used instead of 'user_ids'
+
+See: https://docs.gitlab.com/ee/api/merge_request_approvals.html#create-project-level-rule
+
+- **api**: Add support for events scope parameter
+  ([`348f56e`](https://github.com/python-gitlab/python-gitlab/commit/348f56e8b95c43a7f140f015d303131665b21772))
+
+
+## v3.14.0 (2023-04-11)
+
+### Bug Fixes
+
+- Support int for `parent_id` in `import_group`
+  ([`90f96ac`](https://github.com/python-gitlab/python-gitlab/commit/90f96acf9e649de9874cec612fc1b49c4a843447))
+
+This will also fix other use cases where an integer is passed in to MultipartEncoder.
+
+Added unit tests to show it works.
+
+Closes: #2506
+
+- **cli**: Add ability to escape at-prefixed parameter
+  ([#2513](https://github.com/python-gitlab/python-gitlab/pull/2513),
+  [`4f7c784`](https://github.com/python-gitlab/python-gitlab/commit/4f7c78436e62bfd21745c5289117e03ed896bc66))
+
+* fix(cli): Add ability to escape at-prefixed parameter (#2511)
+
+---------
+
+Co-authored-by: Nejc Habjan <hab.nejc@gmail.com>
+
+- **cli**: Display items when iterator is returned
+  ([`33a04e7`](https://github.com/python-gitlab/python-gitlab/commit/33a04e74fc42d720c7be32172133a614f7268ec1))
+
+- **cli**: Warn user when no fields are displayed
+  ([`8bf53c8`](https://github.com/python-gitlab/python-gitlab/commit/8bf53c8b31704bdb31ffc5cf107cc5fba5dad457))
+
+- **client**: Properly parse content-type when charset is present
+  ([`76063c3`](https://github.com/python-gitlab/python-gitlab/commit/76063c386ef9caf84ba866515cb053f6129714d9))
+
+### Chores
+
+- Add Contributor Covenant 2.1 as Code of Conduct
+  ([`fe334c9`](https://github.com/python-gitlab/python-gitlab/commit/fe334c91fcb6450f5b3b424c925bf48ec2a3c150))
+
+See https://www.contributor-covenant.org/version/2/1/code_of_conduct/
+
+- Add Python 3.12 testing
+  ([`0867564`](https://github.com/python-gitlab/python-gitlab/commit/08675643e6b306d3ae101b173609a6c363c9f3df))
+
+Add a unit test for Python 3.12. This will use the latest version of Python 3.12 that is available
+  from https://github.com/actions/python-versions/
+
+At this time it is 3.12.0-alpha.4 but will move forward over time until the final 3.12 release and
+  updates. So 3.12.0, 3.12.1, ... will be matched.
+
+- Add SECURITY.md
+  ([`572ca3b`](https://github.com/python-gitlab/python-gitlab/commit/572ca3b6bfe190f8681eef24e72b15c1f8ba6da8))
+
+- Remove `pre-commit` as a default `tox` environment
+  ([#2470](https://github.com/python-gitlab/python-gitlab/pull/2470),
+  [`fde2495`](https://github.com/python-gitlab/python-gitlab/commit/fde2495dd1e97fd2f0e91063946bb08490b3952c))
+
+For users who use `tox` having `pre-commit` as part of the default environment list is redundant as
+  it will run the same tests again that are being run in other environments. For example: black,
+  flake8, pylint, and more.
+
+- Use a dataclass to return values from `prepare_send_data`
+  ([`f2b5e4f`](https://github.com/python-gitlab/python-gitlab/commit/f2b5e4fa375e88d6102a8d023ae2fe8206042545))
+
+I found the tuple of three values confusing. So instead use a dataclass to return the three values.
+  It is still confusing but a little bit less so.
+
+Also add some unit tests
+
+- **.github**: Actually make PR template the default
+  ([`7a8a862`](https://github.com/python-gitlab/python-gitlab/commit/7a8a86278543a1419d07dd022196e4cb3db12d31))
+
+- **ci**: Wait for all coverage reports in CI status
+  ([`511764d`](https://github.com/python-gitlab/python-gitlab/commit/511764d2fc4e524eff0d7cf0987d451968e817d3))
+
+- **contributing**: Refresh development docs
+  ([`d387d91`](https://github.com/python-gitlab/python-gitlab/commit/d387d91401fdf933b1832ea2593614ea6b7d8acf))
+
+- **deps**: Update actions/stale action to v8
+  ([`7ac4b86`](https://github.com/python-gitlab/python-gitlab/commit/7ac4b86fe3d24c3347a1c44bd3db561d62a7bd3f))
+
+- **deps**: Update all non-major dependencies
+  ([`8b692e8`](https://github.com/python-gitlab/python-gitlab/commit/8b692e825d95cd338e305196d9ca4e6d87173a84))
+
+- **deps**: Update all non-major dependencies
+  ([`2f06999`](https://github.com/python-gitlab/python-gitlab/commit/2f069999c5dfd637f17d1ded300ea7628c0566c3))
+
+- **deps**: Update all non-major dependencies
+  ([#2493](https://github.com/python-gitlab/python-gitlab/pull/2493),
+  [`07d03dc`](https://github.com/python-gitlab/python-gitlab/commit/07d03dc959128e05d21e8dfd79aa8e916ab5b150))
+
+* chore(deps): update all non-major dependencies * chore(fixtures): downgrade GitLab for now *
+  chore(deps): ungroup typing deps, group gitlab instead * chore(deps): downgrade argcomplete for
+  now
+
+---------
+
+Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
+
+Co-authored-by: Nejc Habjan <nejc.habjan@siemens.com>
+
+- **deps**: Update black (23.1.0) and commitizen (2.40.0)
+  ([#2479](https://github.com/python-gitlab/python-gitlab/pull/2479),
+  [`44786ef`](https://github.com/python-gitlab/python-gitlab/commit/44786efad1dbb66c8242e61cf0830d58dfaff196))
+
+Update the dependency versions: black: 23.1.0
+
+commitizen: 2.40.0
+
+They needed to be updated together as just updating `black` caused a dependency conflict.
+
+Updated files by running `black` and committing the changes.
+
+- **deps**: Update dependency coverage to v7
+  ([#2501](https://github.com/python-gitlab/python-gitlab/pull/2501),
+  [`aee73d0`](https://github.com/python-gitlab/python-gitlab/commit/aee73d05c8c9bd94fb7f01dfefd1bb6ad19c4eb2))
+
+Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
+
+- **deps**: Update dependency flake8 to v6
+  ([#2502](https://github.com/python-gitlab/python-gitlab/pull/2502),
+  [`3d4596e`](https://github.com/python-gitlab/python-gitlab/commit/3d4596e8cdebbc0ea214d63556b09eac40d42a9c))
+
+Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
+
+- **deps**: Update dependency furo to v2023
+  ([`7a1545d`](https://github.com/python-gitlab/python-gitlab/commit/7a1545d52ed0ac8e2e42a2f260e8827181e94d88))
+
+- **deps**: Update dependency pre-commit to v3
+  ([#2508](https://github.com/python-gitlab/python-gitlab/pull/2508),
+  [`7d779c8`](https://github.com/python-gitlab/python-gitlab/commit/7d779c85ffe09623c5d885b5a429b0242ad82f93))
+
+Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
+
+- **deps**: Update mypy (1.0.0) and responses (0.22.0)
+  ([`9c24657`](https://github.com/python-gitlab/python-gitlab/commit/9c2465759386b60a478bd8f43e967182ed97d39d))
+
+Update the `requirements-*` files.
+
+In order to update mypy==1.0.0 we need to also update responses==0.22.0
+
+Fix one issue found by `mypy`
+
+Leaving updates for `precommit` to be done in a separate commit by someone.
+
+- **deps**: Update pre-commit hook psf/black to v23
+  ([`217a787`](https://github.com/python-gitlab/python-gitlab/commit/217a78780c3ae6e41fb9d76d4d841c5d576de45f))
+
+- **github**: Add default pull request template
+  ([`bf46c67`](https://github.com/python-gitlab/python-gitlab/commit/bf46c67db150f0657b791d94e6699321c9985f57))
+
+- **pre-commit**: Bumping versions
+  ([`e973729`](https://github.com/python-gitlab/python-gitlab/commit/e973729e007f664aa4fde873654ef68c21be03c8))
+
+- **renovate**: Bring back custom requirements pattern
+  ([`ae0b21c`](https://github.com/python-gitlab/python-gitlab/commit/ae0b21c1c2b74bf012e099ae1ff35ce3f40c6480))
+
+- **renovate**: Do not ignore tests dir
+  ([`5b8744e`](https://github.com/python-gitlab/python-gitlab/commit/5b8744e9c2241e0fdcdef03184afcb48effea90f))
+
+- **renovate**: Swith to gitlab-ee
+  ([`8da48ee`](https://github.com/python-gitlab/python-gitlab/commit/8da48ee0f32c293b4788ebd0ddb24018401ef7ad))
+
+- **setup**: Depend on typing-extensions for 3.7 until EOL
+  ([`3abc557`](https://github.com/python-gitlab/python-gitlab/commit/3abc55727d4d52307b9ce646fee172f94f7baf8d))
+
+### Documentation
+
+- Fix update badge behaviour
+  ([`3d7ca1c`](https://github.com/python-gitlab/python-gitlab/commit/3d7ca1caac5803c2e6d60a3e5eba677957b3cfc6))
+
+docs: fix update badge behaviour
+
+Earlier: badge.image_link = new_link
+
+Now: badge.image_url = new_image_url badge.link_url = new_link_url
+
+- **advanced**: Clarify netrc, proxy behavior with requests
+  ([`1da7c53`](https://github.com/python-gitlab/python-gitlab/commit/1da7c53fd3476a1ce94025bb15265f674af40e1a))
+
+- **advanced**: Fix typo in Gitlab examples
+  ([`1992790`](https://github.com/python-gitlab/python-gitlab/commit/19927906809c329788822f91d0abd8761a85c5c3))
+
+- **objects**: Fix typo in pipeline schedules
+  ([`3057f45`](https://github.com/python-gitlab/python-gitlab/commit/3057f459765d1482986f2086beb9227acc7fd15f))
+
+### Features
+
+- Add resource_weight_event for ProjectIssue
+  ([`6e5ef55`](https://github.com/python-gitlab/python-gitlab/commit/6e5ef55747ddeabe6d212aec50d66442054c2352))
+
+- **backends**: Use PEP544 protocols for structural subtyping
+  ([#2442](https://github.com/python-gitlab/python-gitlab/pull/2442),
+  [`4afeaff`](https://github.com/python-gitlab/python-gitlab/commit/4afeaff0361a966254a7fbf0120e93583d460361))
+
+The purpose of this change is to track API changes described in
+  https://github.com/python-gitlab/python-gitlab/blob/main/docs/api-levels.rst, for example, for
+  package versioning and breaking change announcements in case of protocol changes.
+
+This is MVP implementation to be used by #2435.
+
+- **cli**: Add setting of `allow_force_push` for protected branch
+  ([`929e07d`](https://github.com/python-gitlab/python-gitlab/commit/929e07d94d9a000e6470f530bfde20bb9c0f2637))
+
+For the CLI: add `allow_force_push` as an optional argument for creating a protected branch.
+
+API reference: https://docs.gitlab.com/ee/api/protected_branches.html#protect-repository-branches
+
+Closes: #2466
+
+- **client**: Add http_patch method
+  ([#2471](https://github.com/python-gitlab/python-gitlab/pull/2471),
+  [`f711d9e`](https://github.com/python-gitlab/python-gitlab/commit/f711d9e2bf78f58cee6a7c5893d4acfd2f980397))
+
+In order to support some new API calls we need to support the HTTP `PATCH` method.
+
+Closes: #2469
+
+- **objects**: Support fetching PATs via id or `self` endpoint
+  ([`19b38bd`](https://github.com/python-gitlab/python-gitlab/commit/19b38bd481c334985848be204eafc3f1ea9fe8a6))
+
+- **projects**: Allow importing additional items from GitHub
+  ([`ce84f2e`](https://github.com/python-gitlab/python-gitlab/commit/ce84f2e64a640e0d025a7ba3a436f347ad25e88e))
+
+### Refactoring
+
+- **client**: Let mypy know http_password is set
+  ([`2dd177b`](https://github.com/python-gitlab/python-gitlab/commit/2dd177bf83fdf62f0e9bdcb3bc41d5e4f5631504))
+
+### Testing
+
+- **functional**: Clarify MR fixture factory name
+  ([`d8fd1a8`](https://github.com/python-gitlab/python-gitlab/commit/d8fd1a83b588f4e5e61ca46a28f4935220c5b8c4))
+
+- **meta**: Move meta suite into unit tests
+  ([`847004b`](https://github.com/python-gitlab/python-gitlab/commit/847004be021b4a514e41bf28afb9d87e8643ddba))
+
+They're always run with it anyway, so it makes no difference.
+
+- **unit**: Consistently use inline fixtures
+  ([`1bc56d1`](https://github.com/python-gitlab/python-gitlab/commit/1bc56d164a7692cf3aaeedfa1ed2fb869796df03))
+
+- **unit**: Increase V4 CLI coverage
+  ([`5748d37`](https://github.com/python-gitlab/python-gitlab/commit/5748d37365fdac105341f94eaccde8784d6f57e3))
+
+- **unit**: Remove redundant package
+  ([`4a9e3ee`](https://github.com/python-gitlab/python-gitlab/commit/4a9e3ee70f784f99f373f2fddde0155649ebe859))
+
+- **unit**: Split the last remaining unittest-based classes into modules"
+  ([`14e0f65`](https://github.com/python-gitlab/python-gitlab/commit/14e0f65a3ff05563df4977d792272f8444bf4312))
+
+
+## v3.13.0 (2023-01-30)
+
+### Bug Fixes
+
+- Change return value to "None" in case getattr returns None to prevent error
+  ([`3f86d36`](https://github.com/python-gitlab/python-gitlab/commit/3f86d36218d80b293b346b37f8be5efa6455d10c))
+
+- Typo fixed in docs
+  ([`ee5f444`](https://github.com/python-gitlab/python-gitlab/commit/ee5f444b16e4d2f645499ac06f5d81f22867f050))
+
+- Use the ProjectIterationManager within the Project object
+  ([`44f05dc`](https://github.com/python-gitlab/python-gitlab/commit/44f05dc017c5496e14db82d9650c6a0110b95cf9))
+
+The Project object was previously using the GroupIterationManager resulting in the incorrect API
+  endpoint being used. Utilize the correct ProjectIterationManager instead.
+
+Resolves #2403
+
+- **api**: Make description optional for releases
+  ([`5579750`](https://github.com/python-gitlab/python-gitlab/commit/5579750335245011a3acb9456cb488f0fa1cda61))
+
+- **client**: Regression - do not automatically get_next if page=# and
+  ([`585e3a8`](https://github.com/python-gitlab/python-gitlab/commit/585e3a86c4cafa9ee73ed38676a78f3c34dbe6b2))
+
+- **deps**: Bump requests-toolbelt to fix deprecation warning
+  ([`faf842e`](https://github.com/python-gitlab/python-gitlab/commit/faf842e97d4858ff5ebd8ae6996e0cb3ca29881c))
+
+### Chores
+
+- Add a UserWarning if both `iterator=True` and `page=X` are used
+  ([#2462](https://github.com/python-gitlab/python-gitlab/pull/2462),
+  [`8e85791`](https://github.com/python-gitlab/python-gitlab/commit/8e85791c315822cd26d56c0c0f329cffae879644))
+
+If a caller calls a `list()` method with both `iterator=True` (or `as_list=False`) and `page=X` then
+  emit a `UserWarning` as the options are mutually exclusive.
+
+- Add docs for schedule pipelines
+  ([`9a9a6a9`](https://github.com/python-gitlab/python-gitlab/commit/9a9a6a98007df2992286a721507b02c48800bfed))
+
+- Add test, docs, and helper for 409 retries
+  ([`3e1c625`](https://github.com/python-gitlab/python-gitlab/commit/3e1c625133074ccd2fb88c429ea151bfda96aebb))
+
+- Make backends private
+  ([`1e629af`](https://github.com/python-gitlab/python-gitlab/commit/1e629af73e312fea39522334869c3a9b7e6085b9))
+
+- Remove tox `envdir` values
+  ([`3c7c7fc`](https://github.com/python-gitlab/python-gitlab/commit/3c7c7fc9d2375d3219fb078e18277d7476bae5e0))
+
+tox > 4 no longer will re-use the tox directory :( What this means is that with the previous config
+  if you ran: $ tox -e mypy; tox -e isort; tox -e mypy It would recreate the tox environment each
+  time :(
+
+By removing the `envdir` values it will have the tox environments in separate directories and not
+  recreate them.
+
+The have an FAQ entry about this: https://tox.wiki/en/latest/upgrading.html#re-use-of-environments
+
+- Update attributes for create and update projects
+  ([`aa44f2a`](https://github.com/python-gitlab/python-gitlab/commit/aa44f2aed8150f8c891837e06296c7bbef17c292))
+
+- Use SPDX license expression in project metadata
+  ([`acb3a4a`](https://github.com/python-gitlab/python-gitlab/commit/acb3a4ad1fa23c21b1d7f50e95913136beb61402))
+
+- **ci**: Complete all unit tests even if one has failed
+  ([#2438](https://github.com/python-gitlab/python-gitlab/pull/2438),
+  [`069c6c3`](https://github.com/python-gitlab/python-gitlab/commit/069c6c30ff989f89356898b72835b4f4a792305c))
+
+- **deps**: Update actions/download-artifact action to v3
+  ([`64ca597`](https://github.com/python-gitlab/python-gitlab/commit/64ca5972468ab3b7e3a01e88ab9bb8e8bb9a3de1))
+
+- **deps**: Update actions/stale action to v7
+  ([`76eb024`](https://github.com/python-gitlab/python-gitlab/commit/76eb02439c0ae0f7837e3408948840c800fd93a7))
+
+- **deps**: Update all non-major dependencies
+  ([`ea7010b`](https://github.com/python-gitlab/python-gitlab/commit/ea7010b17cc2c29c2a5adeaf81f2d0064523aa39))
+
+- **deps**: Update all non-major dependencies
+  ([`122988c`](https://github.com/python-gitlab/python-gitlab/commit/122988ceb329d7162567cb4a325f005ea2013ef2))
+
+- **deps**: Update all non-major dependencies
+  ([`49c0233`](https://github.com/python-gitlab/python-gitlab/commit/49c023387970abea7688477c8ef3ff3a1b31b0bc))
+
+- **deps**: Update all non-major dependencies
+  ([`10c4f31`](https://github.com/python-gitlab/python-gitlab/commit/10c4f31ad1480647a6727380db68f67a4c645af9))
+
+- **deps**: Update all non-major dependencies
+  ([`bbd01e8`](https://github.com/python-gitlab/python-gitlab/commit/bbd01e80326ea9829b2f0278fedcb4464be64389))
+
+- **deps**: Update all non-major dependencies
+  ([`6682808`](https://github.com/python-gitlab/python-gitlab/commit/6682808034657b73c4b72612aeb009527c25bfa2))
+
+- **deps**: Update all non-major dependencies
+  ([`1816107`](https://github.com/python-gitlab/python-gitlab/commit/1816107b8d87614e7947837778978d8de8da450f))
+
+- **deps**: Update all non-major dependencies
+  ([`21e767d`](https://github.com/python-gitlab/python-gitlab/commit/21e767d8719372daadcea446f835f970210a6b6b))
+
+- **deps**: Update dessant/lock-threads action to v4
+  ([`337b25c`](https://github.com/python-gitlab/python-gitlab/commit/337b25c6fc1f40110ef7a620df63ff56a45579f1))
+
+- **deps**: Update pre-commit hook maxbrunet/pre-commit-renovate to v34.48.4
+  ([`985b971`](https://github.com/python-gitlab/python-gitlab/commit/985b971cf6d69692379805622a1bb1ff29ae308d))
+
+- **deps**: Update pre-commit hook pycqa/flake8 to v6
+  ([`82c61e1`](https://github.com/python-gitlab/python-gitlab/commit/82c61e1d2c3a8102c320558f46e423b09c6957aa))
+
+- **tox**: Ensure test envs have all dependencies
+  ([`63cf4e4`](https://github.com/python-gitlab/python-gitlab/commit/63cf4e4fa81d6c5bf6cf74284321bc3ce19bab62))
+
+### Documentation
+
+- **faq**: Describe and group common errors
+  ([`4c9a072`](https://github.com/python-gitlab/python-gitlab/commit/4c9a072b053f12f8098e4ea6fc47e3f6ab4f8b07))
+
+### Features
+
+- Add keep_base_url when getting configuration from file
+  ([`50a0301`](https://github.com/python-gitlab/python-gitlab/commit/50a03017f2ba8ec3252911dd1cf0ed7df42cfe50))
+
+- Add resource iteration events (see https://docs.gitlab.com/ee/api/resource_iteration_events.html)
+  ([`ef5feb4`](https://github.com/python-gitlab/python-gitlab/commit/ef5feb4d07951230452a2974da729a958bdb9d6a))
+
+- Allow filtering pipelines by source
+  ([`b6c0872`](https://github.com/python-gitlab/python-gitlab/commit/b6c08725042380d20ef5f09979bc29f2f6c1ab6f))
+
+See: https://docs.gitlab.com/ee/api/pipelines.html#list-project-pipelines Added in GitLab 14.3
+
+- Allow passing kwargs to Gitlab class when instantiating with `from_config`
+  ([#2392](https://github.com/python-gitlab/python-gitlab/pull/2392),
+  [`e88d34e`](https://github.com/python-gitlab/python-gitlab/commit/e88d34e38dd930b00d7bb48f0e1c39420e09fa0f))
+
+- **api**: Add support for bulk imports API
+  ([`043de2d`](https://github.com/python-gitlab/python-gitlab/commit/043de2d265e0e5114d1cd901f82869c003413d9b))
+
+- **api**: Add support for resource groups
+  ([`5f8b8f5`](https://github.com/python-gitlab/python-gitlab/commit/5f8b8f5be901e944dfab2257f9e0cc4b2b1d2cd5))
+
+- **api**: Support listing pipelines triggered by pipeline schedules
+  ([`865fa41`](https://github.com/python-gitlab/python-gitlab/commit/865fa417a20163b526596549b9afbce679fc2817))
+
+- **client**: Automatically retry on HTTP 409 Resource lock
+  ([`dced76a`](https://github.com/python-gitlab/python-gitlab/commit/dced76a9900c626c9f0b90b85a5e371101a24fb4))
+
+Fixes: #2325
+
+- **client**: Bootstrap the http backends concept
+  ([#2391](https://github.com/python-gitlab/python-gitlab/pull/2391),
+  [`91a665f`](https://github.com/python-gitlab/python-gitlab/commit/91a665f331c3ffc260db3470ad71fde0d3b56aa2))
+
+- **group**: Add support for group restore API
+  ([`9322db6`](https://github.com/python-gitlab/python-gitlab/commit/9322db663ecdaecf399e3192810d973c6a9a4020))
+
+### Refactoring
+
+- Add reason property to RequestsResponse
+  ([#2439](https://github.com/python-gitlab/python-gitlab/pull/2439),
+  [`b59b7bd`](https://github.com/python-gitlab/python-gitlab/commit/b59b7bdb221ac924b5be4227ef7201d79b40c98f))
+
+- Migrate MultipartEncoder to RequestsBackend
+  ([#2421](https://github.com/python-gitlab/python-gitlab/pull/2421),
+  [`43b369f`](https://github.com/python-gitlab/python-gitlab/commit/43b369f28cb9009e02bc23e772383d9ea1ded46b))
+
+- Move Response object to backends
+  ([#2420](https://github.com/python-gitlab/python-gitlab/pull/2420),
+  [`7d9ce0d`](https://github.com/python-gitlab/python-gitlab/commit/7d9ce0dfb9f5a71aaa7f9c78d815d7c7cbd21c1c))
+
+- Move the request call to the backend
+  ([#2413](https://github.com/python-gitlab/python-gitlab/pull/2413),
+  [`283e7cc`](https://github.com/python-gitlab/python-gitlab/commit/283e7cc04ce61aa456be790a503ed64089a2c2b6))
+
+- Moving RETRYABLE_TRANSIENT_ERROR_CODES to const
+  ([`887852d`](https://github.com/python-gitlab/python-gitlab/commit/887852d7ef02bed6dff5204ace73d8e43a66e32f))
+
+- Remove unneeded requests.utils import
+  ([#2426](https://github.com/python-gitlab/python-gitlab/pull/2426),
+  [`6fca651`](https://github.com/python-gitlab/python-gitlab/commit/6fca6512a32e9e289f988900e1157dfe788f54be))
+
+### Testing
+
+- **functional**: Do not require config file
+  ([`43c2dda`](https://github.com/python-gitlab/python-gitlab/commit/43c2dda7aa8b167a451b966213e83d88d1baa1df))
+
+- **unit**: Expand tests for pipeline schedules
+  ([`c7cf0d1`](https://github.com/python-gitlab/python-gitlab/commit/c7cf0d1f172c214a11b30622fbccef57d9c86e93))
+
+
+## v3.12.0 (2022-11-28)
+
+### Bug Fixes
+
+- Use POST method and return dict in `cancel_merge_when_pipeline_succeeds()`
+  ([#2350](https://github.com/python-gitlab/python-gitlab/pull/2350),
+  [`bd82d74`](https://github.com/python-gitlab/python-gitlab/commit/bd82d745c8ea9ff6ff078a4c961a2d6e64a2f63c))
+
+* Call was incorrectly using a `PUT` method when should have used a `POST` method. * Changed return
+  type to a `dict` as GitLab only returns {'status': 'success'} on success. Since the function
+  didn't work previously, this should not impact anyone. * Updated the test fixture `merge_request`
+  to add ability to create a pipeline. * Added functional test for
+  `mr.cancel_merge_when_pipeline_succeeds()`
+
+Fixes: #2349
+
+- **cli**: Enable debug before doing auth
+  ([`65abb85`](https://github.com/python-gitlab/python-gitlab/commit/65abb85be7fc8ef57b295296111dac0a97ed1c49))
+
+Authentication issues are currently hard to debug since `--debug` only has effect after `gl.auth()`
+  has been called.
+
+For example, a 401 error is printed without any details about the actual HTTP request being sent:
+
+$ gitlab --debug --server-url https://gitlab.com current-user get 401: 401 Unauthorized
+
+By moving the call to `gl.enable_debug()` the usual debug logs get printed before the final error
+  message.
+
+Signed-off-by: Emanuele Aina <emanuele.aina@collabora.com>
+
+- **cli**: Expose missing mr_default_target_self project attribute
+  ([`12aea32`](https://github.com/python-gitlab/python-gitlab/commit/12aea32d1c0f7e6eac0d19da580bf6efde79d3e2))
+
+Example::
+
+gitlab project update --id 616 --mr-default-target-self 1
+
+References:
+
+* https://gitlab.com/gitlab-org/gitlab/-/merge_requests/58093 *
+  https://gitlab.com/gitlab-org/gitlab/-/blob/v13.11.0-ee/doc/user/project/merge_requests/creating_merge_requests.md#new-merge-request-from-a-fork
+  * https://gitlab.com/gitlab-org/gitlab/-/blob/v14.7.0-ee/doc/api/projects.md#get-single-project
+
+### Chores
+
+- Correct website for pylint
+  ([`fcd72fe`](https://github.com/python-gitlab/python-gitlab/commit/fcd72fe243daa0623abfde267c7ab1c6866bcd52))
+
+Use https://github.com/PyCQA/pylint as the website for pylint.
+
+- Validate httpx package is not installed by default
+  ([`0ecf3bb`](https://github.com/python-gitlab/python-gitlab/commit/0ecf3bbe28c92fd26a7d132bf7f5ae9481cbad30))
+
+- **deps**: Update all non-major dependencies
+  ([`d8a657b`](https://github.com/python-gitlab/python-gitlab/commit/d8a657b2b391e9ba3c20d46af6ad342a9b9a2f93))
+
+- **deps**: Update all non-major dependencies
+  ([`b2c6d77`](https://github.com/python-gitlab/python-gitlab/commit/b2c6d774b3f8fa72c5607bfa4fa0918283bbdb82))
+
+- **deps**: Update pre-commit hook maxbrunet/pre-commit-renovate to v34
+  ([`623e768`](https://github.com/python-gitlab/python-gitlab/commit/623e76811a16f0a8ae58dbbcebfefcfbef97c8d1))
+
+- **deps**: Update pre-commit hook maxbrunet/pre-commit-renovate to v34.20.0
+  ([`e6f1bd6`](https://github.com/python-gitlab/python-gitlab/commit/e6f1bd6333a884433f808b2a84670079f9a70f0a))
+
+- **deps**: Update pre-commit hook maxbrunet/pre-commit-renovate to v34.24.0
+  ([`a0553c2`](https://github.com/python-gitlab/python-gitlab/commit/a0553c29899f091209afe6366e8fb75fb9edef40))
+
+### Documentation
+
+- Use the term "log file" for getting a job log file
+  ([`9d2b1ad`](https://github.com/python-gitlab/python-gitlab/commit/9d2b1ad10aaa78a5c28ece334293641c606291b5))
+
+The GitLab docs refer to it as a log file: https://docs.gitlab.com/ee/api/jobs.html#get-a-log-file
+
+"trace" is the endpoint name but not a common term people will think of for a "log file"
+
+- **api**: Pushrules remove saying `None` is returned when not found
+  ([`c3600b4`](https://github.com/python-gitlab/python-gitlab/commit/c3600b49e4d41b1c4f2748dd6f2a331c331d8706))
+
+In `groups.pushrules.get()`, GitLab does not return `None` when no rules are found. GitLab returns a
+  404.
+
+Update docs to not say it will return `None`
+
+Also update docs in `project.pushrules.get()` to be consistent. Not 100% sure if it returns `None`
+  or returns a 404, but we don't need to document that.
+
+Closes: #2368
+
+- **groups**: Describe GitLab.com group creation limitation
+  ([`9bd433a`](https://github.com/python-gitlab/python-gitlab/commit/9bd433a3eb508b53fbca59f3f445da193522646a))
+
+### Features
+
+- Add support for SAML group links
+  ([#2367](https://github.com/python-gitlab/python-gitlab/pull/2367),
+  [`1020ce9`](https://github.com/python-gitlab/python-gitlab/commit/1020ce965ff0cd3bfc283d4f0ad40e41e4d1bcee))
+
+- Implement secure files API
+  ([`d0a0348`](https://github.com/python-gitlab/python-gitlab/commit/d0a034878fabfd8409134aa8b7ffeeb40219683c))
+
+- **api**: Add application statistics
+  ([`6fcf3b6`](https://github.com/python-gitlab/python-gitlab/commit/6fcf3b68be095e614b969f5922ad8a67978cd4db))
+
+- **api**: Add support for getting a project's pull mirror details
+  ([`060cfe1`](https://github.com/python-gitlab/python-gitlab/commit/060cfe1465a99657c5f832796ab3aa03aad934c7))
+
+Add the ability to get a project's pull mirror details. This was added in GitLab 15.5 and is a
+  PREMIUM feature.
+
+https://docs.gitlab.com/ee/api/projects.html#get-a-projects-pull-mirror-details
+
+- **api**: Add support for remote project import
+  ([#2348](https://github.com/python-gitlab/python-gitlab/pull/2348),
+  [`e5dc72d`](https://github.com/python-gitlab/python-gitlab/commit/e5dc72de9b3cdf0a7944ee0961fbdc6784c7f315))
+
+- **api**: Add support for remote project import from AWS S3
+  ([#2357](https://github.com/python-gitlab/python-gitlab/pull/2357),
+  [`892281e`](https://github.com/python-gitlab/python-gitlab/commit/892281e35e3d81c9e43ff6a974f920daa83ea8b2))
+
+- **ci**: Re-run Tests on PR Comment workflow
+  ([`034cde3`](https://github.com/python-gitlab/python-gitlab/commit/034cde31c7017923923be29c3f34783937febc0f))
+
+- **groups**: Add LDAP link manager and deprecate old API endpoints
+  ([`3a61f60`](https://github.com/python-gitlab/python-gitlab/commit/3a61f601adaec7751cdcfbbcb88aa544326b1730))
+
+- **groups**: Add support for listing ldap_group_links
+  ([#2371](https://github.com/python-gitlab/python-gitlab/pull/2371),
+  [`ad7c8fa`](https://github.com/python-gitlab/python-gitlab/commit/ad7c8fafd56866002aa6723ceeba4c4bc071ca0d))
+
+### Refactoring
+
+- Explicitly use ProjectSecureFile
+  ([`0c98b2d`](https://github.com/python-gitlab/python-gitlab/commit/0c98b2d8f4b8c1ac6a4b496282f307687b652759))
+
+### Testing
+
+- **api**: Fix flaky test `test_cancel_merge_when_pipeline_succeeds`
+  ([`6525c17`](https://github.com/python-gitlab/python-gitlab/commit/6525c17b8865ead650a6e09f9bf625ca9881911b))
+
+This is an attempt to fix the flaky test `test_cancel_merge_when_pipeline_succeeds`. Were seeing a:
+  405 Method Not Allowed error when setting the MR to merge_when_pipeline_succeeds.
+
+Closes: #2383
+
+
+## v3.11.0 (2022-10-28)
+
+### Bug Fixes
+
+- Intermittent failure in test_merge_request_reset_approvals
+  ([`3dde36e`](https://github.com/python-gitlab/python-gitlab/commit/3dde36eab40406948adca633f7197beb32b29552))
+
+Have been seeing intermittent failures in the test:
+  tests/functional/api/test_merge_requests.py::test_merge_request_reset_approvals
+
+Also saw a failure in: tests/functional/cli/test_cli_v4.py::test_accept_request_merge[subprocess]
+
+Add a call to `wait_for_sidekiq()` to hopefully resolve the issues.
+
+- Remove `project.approvals.set_approvals()` method
+  ([`91f08f0`](https://github.com/python-gitlab/python-gitlab/commit/91f08f01356ca5e38d967700a5da053f05b6fab0))
+
+The `project.approvals.set_approvals()` method used the `/projects/:id/approvers` end point. That
+  end point was removed from GitLab in the 13.11 release, on 2-Apr-2021 in commit
+  27dc2f2fe81249bbdc25f7bd8fe799752aac05e6 via merge commit
+  e482597a8cf1bae8e27abd6774b684fb90491835. It was deprecated on 19-Aug-2019.
+
+See merge request: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57473
+
+- Use epic id instead of iid for epic notes
+  ([`97cae38`](https://github.com/python-gitlab/python-gitlab/commit/97cae38a315910972279f2d334e91fa54d9ede0c))
+
+- **cli**: Handle list response for json/yaml output
+  ([`9b88132`](https://github.com/python-gitlab/python-gitlab/commit/9b88132078ed37417c2a45369b4976c9c67f7882))
+
+Handle the case with the CLI where a list response is returned from GitLab and json/yaml output is
+  requested.
+
+Add a functional CLI test to validate it works.
+
+Closes: #2287
+
+### Chores
+
+- Add `not-callable` to pylint ignore list
+  ([`f0c02a5`](https://github.com/python-gitlab/python-gitlab/commit/f0c02a553da05ea3fdca99798998f40cfd820983))
+
+The `not-callable` error started showing up. Ignore this error as it is invalid. Also `mypy` tests
+  for these issues.
+
+Closes: #2334
+
+- Add basic type checks to functional/api tests
+  ([`5b642a5`](https://github.com/python-gitlab/python-gitlab/commit/5b642a5d4c934f0680fa99079484176d36641861))
+
+- Add basic type checks to meta tests
+  ([`545d6d6`](https://github.com/python-gitlab/python-gitlab/commit/545d6d60673c7686ec873a343b6afd77ec9062ec))
+
+- Add basic typing to functional tests
+  ([`ee143c9`](https://github.com/python-gitlab/python-gitlab/commit/ee143c9d6df0f1498483236cc228e12132bef132))
+
+- Add basic typing to smoke tests
+  ([`64e8c31`](https://github.com/python-gitlab/python-gitlab/commit/64e8c31e1d35082bc2e52582205157ae1a6c4605))
+
+- Add basic typing to test root
+  ([`0b2f6bc`](https://github.com/python-gitlab/python-gitlab/commit/0b2f6bcf454685786a89138b36b10fba649663dd))
+
+- Add responses to pre-commit deps
+  ([`4b8ddc7`](https://github.com/python-gitlab/python-gitlab/commit/4b8ddc74c8f7863631005e8eb9861f1e2f0a4cbc))
+
+- Fix flaky test
+  ([`fdd4114`](https://github.com/python-gitlab/python-gitlab/commit/fdd4114097ca69bbb4fd9c3117b83063b242f8f2))
+
+- Narrow type hints for license API
+  ([`50731c1`](https://github.com/python-gitlab/python-gitlab/commit/50731c173083460f249b1718cbe2288fc3c46c1a))
+
+- Renovate and precommit cleanup
+  ([`153d373`](https://github.com/python-gitlab/python-gitlab/commit/153d3739021d2375438fe35ce819c77142914567))
+
+- Revert compose upgrade
+  ([`dd04e8e`](https://github.com/python-gitlab/python-gitlab/commit/dd04e8ef7eee2793fba38a1eec019b00b3bb616e))
+
+This reverts commit f825d70e25feae8cd9da84e768ec6075edbc2200.
+
+- Simplify `wait_for_sidekiq` usage
+  ([`196538b`](https://github.com/python-gitlab/python-gitlab/commit/196538ba3e233ba2acf6f816f436888ba4b1f52a))
+
+Simplify usage of `wait_for_sidekiq` by putting the assert if it timed out inside the function
+  rather than after calling it.
+
+- Topic functional tests
+  ([`d542eba`](https://github.com/python-gitlab/python-gitlab/commit/d542eba2de95f2cebcc6fc7d343b6daec95e4219))
+
+- Update the issue templates
+  ([`c15bd33`](https://github.com/python-gitlab/python-gitlab/commit/c15bd33f45fbd9d064f1e173c6b3ca1b216def2f))
+
+* Have an option to go to the discussions * Have an option to go to the Gitter chat * Move the
+  bug/issue template into the .github/ISSUE_TEMPLATE/ directory
+
+- Use kwargs for http_request docs
+  ([`124abab`](https://github.com/python-gitlab/python-gitlab/commit/124abab483ab6be71dbed91b8d518ae27355b9ae))
+
+- **deps**: Group non-major upgrades to reduce noise
+  ([`37d14bd`](https://github.com/python-gitlab/python-gitlab/commit/37d14bd9fd399a498d72a03b536701678af71702))
+
+- **deps**: Pin and clean up test dependencies
+  ([`60b9197`](https://github.com/python-gitlab/python-gitlab/commit/60b9197dfe327eb2310523bae04c746d34458fa3))
+
+- **deps**: Pin dependencies
+  ([`953f38d`](https://github.com/python-gitlab/python-gitlab/commit/953f38dcc7ccb2a9ad0ea8f1b9a9e06bd16b9133))
+
+- **deps**: Pin GitHub Actions
+  ([`8dbaa5c`](https://github.com/python-gitlab/python-gitlab/commit/8dbaa5cddef6d7527ded686553121173e33d2973))
+
+- **deps**: Update all non-major dependencies
+  ([`dde3642`](https://github.com/python-gitlab/python-gitlab/commit/dde3642bcd41ea17c4f301188cb571db31fe4da8))
+
+- **deps**: Update all non-major dependencies
+  ([`2966234`](https://github.com/python-gitlab/python-gitlab/commit/296623410ae0b21454ac11e48e5991329c359c4d))
+
+- **deps**: Update black to v22.10.0
+  ([`531ee05`](https://github.com/python-gitlab/python-gitlab/commit/531ee05bdafbb6fee8f6c9894af15fc89c67d610))
+
+- **deps**: Update dependency commitizen to v2.35.0
+  ([`4ce9559`](https://github.com/python-gitlab/python-gitlab/commit/4ce95594695d2e19a215719d535bc713cf381729))
+
+- **deps**: Update dependency mypy to v0.981
+  ([`da48849`](https://github.com/python-gitlab/python-gitlab/commit/da48849a303beb0d0292bccd43d54aacfb0c316b))
+
+- **deps**: Update dependency pylint to v2.15.3
+  ([`6627a60`](https://github.com/python-gitlab/python-gitlab/commit/6627a60a12471f794cb308e76e449b463b9ce37a))
+
+- **deps**: Update dependency types-requests to v2.28.11.2
+  ([`d47c0f0`](https://github.com/python-gitlab/python-gitlab/commit/d47c0f06317d6a63af71bb261d6bb4e83325f261))
+
+- **deps**: Update pre-commit hook maxbrunet/pre-commit-renovate to v33
+  ([`932bbde`](https://github.com/python-gitlab/python-gitlab/commit/932bbde7ff10dd0f73bc81b7e91179b93a64602b))
+
+- **deps**: Update typing dependencies
+  ([`81285fa`](https://github.com/python-gitlab/python-gitlab/commit/81285fafd2b3c643d130a84550a666d4cc480b51))
+
+### Documentation
+
+- Add minimal docs about the `enable_debug()` method
+  ([`b4e9ab7`](https://github.com/python-gitlab/python-gitlab/commit/b4e9ab7ee395e575f17450c2dc0d519f7192e58e))
+
+Add some minimal documentation about the `enable_debug()` method.
+
+- **advanced**: Add hint on type narrowing
+  ([`a404152`](https://github.com/python-gitlab/python-gitlab/commit/a40415290923d69d087dd292af902efbdfb5c258))
+
+- **api**: Describe the list() and all() runners' functions
+  ([`b6cc3f2`](https://github.com/python-gitlab/python-gitlab/commit/b6cc3f255532521eb259b42780354e03ce51458e))
+
+- **api**: Describe use of lower-level methods
+  ([`b7a6874`](https://github.com/python-gitlab/python-gitlab/commit/b7a687490d2690e6bd4706391199135e658e1dc6))
+
+- **api**: Update `merge_requests.rst`: `mr_id` to `mr_iid`
+  ([`b32234d`](https://github.com/python-gitlab/python-gitlab/commit/b32234d1f8c4492b6b2474f91be9479ad23115bb))
+
+Typo: Author probably meant `mr_iid` (i.e. project-specific MR ID)
+
+and **not** `mr_id` (i.e. server-wide MR ID)
+
+Closes: https://github.com/python-gitlab/python-gitlab/issues/2295
+
+Signed-off-by: Stavros Ntentos <133706+stdedos@users.noreply.github.com>
+
+- **commits**: Fix commit create example for binary content
+  ([`bcc1eb4`](https://github.com/python-gitlab/python-gitlab/commit/bcc1eb4571f76b3ca0954adb5525b26f05958e3f))
+
+- **readme**: Add a basic feature list
+  ([`b4d53f1`](https://github.com/python-gitlab/python-gitlab/commit/b4d53f1abb264cd9df8e4ac6560ab0895080d867))
+
+### Features
+
+- **api**: Add support for topics merge API
+  ([`9a6d197`](https://github.com/python-gitlab/python-gitlab/commit/9a6d197f9d2a88bdba8dab1f9abaa4e081a14792))
+
+- **build**: Officially support Python 3.11
+  ([`74f66c7`](https://github.com/python-gitlab/python-gitlab/commit/74f66c71f3974cf68f5038f4fc3995e53d44aebe))
+
+### Refactoring
+
+- Migrate legacy EE tests to pytest
+  ([`88c2505`](https://github.com/python-gitlab/python-gitlab/commit/88c2505b05dbcfa41b9e0458d4f2ec7dcc6f8169))
+
+- Pre-commit trigger from tox
+  ([`6e59c12`](https://github.com/python-gitlab/python-gitlab/commit/6e59c12fe761e8deea491d1507beaf00ca381cdc))
+
+- Pytest-docker fixtures
+  ([`3e4781a`](https://github.com/python-gitlab/python-gitlab/commit/3e4781a66577a6ded58f721739f8e9422886f9cd))
+
+- **deps**: Drop compose v1 dependency in favor of v2
+  ([`f825d70`](https://github.com/python-gitlab/python-gitlab/commit/f825d70e25feae8cd9da84e768ec6075edbc2200))
+
+### Testing
+
+- Enable skipping tests per GitLab plan
+  ([`01d5f68`](https://github.com/python-gitlab/python-gitlab/commit/01d5f68295b62c0a8bd431a9cd31bf9e4e91e7d9))
+
+- Fix `test_project_push_rules` test
+  ([`8779cf6`](https://github.com/python-gitlab/python-gitlab/commit/8779cf672af1abd1a1f67afef20a61ae5876a724))
+
+Make the `test_project_push_rules` test work.
+
+- Use false instead of /usr/bin/false
+  ([`51964b3`](https://github.com/python-gitlab/python-gitlab/commit/51964b3142d4d19f44705fde8e7e721233c53dd2))
+
+On Debian systems false is located at /bin/false (coreutils package). This fixes unit test failure
+  on Debian system:
+
+FileNotFoundError: [Errno 2] No such file or directory: '/usr/bin/false'
+
+/usr/lib/python3.10/subprocess.py:1845: FileNotFoundError
+
+
+## v3.10.0 (2022-09-28)
+
+### Bug Fixes
+
+- **cli**: Add missing attribute for MR changes
+  ([`20c46a0`](https://github.com/python-gitlab/python-gitlab/commit/20c46a0572d962f405041983e38274aeb79a12e4))
+
+- **cli**: Add missing attributes for creating MRs
+  ([`1714d0a`](https://github.com/python-gitlab/python-gitlab/commit/1714d0a980afdb648d203751dedf95ee95ac326e))
+
+### Chores
+
+- Bump GitLab docker image to 15.4.0-ee.0
+  ([`b87a2bc`](https://github.com/python-gitlab/python-gitlab/commit/b87a2bc7cfacd3a3c4a18342c07b89356bf38d50))
+
+* Use `settings.delayed_group_deletion=False` as that is the recommended method to turn off the
+  delayed group deletion now. * Change test to look for `default` as `pages` is not mentioned in the
+  docs[1]
+
+[1] https://docs.gitlab.com/ee/api/sidekiq_metrics.html#get-the-current-queue-metrics
+
+- **deps**: Update black to v22.8.0
+  ([`86b0e40`](https://github.com/python-gitlab/python-gitlab/commit/86b0e4015a258433528de0a5b063defa3eeb3e26))
+
+- **deps**: Update dependency commitizen to v2.32.2
+  ([`31aea28`](https://github.com/python-gitlab/python-gitlab/commit/31aea286e0767148498af300e78db7dbdf715bda))
+
+- **deps**: Update dependency commitizen to v2.32.5
+  ([`e180f14`](https://github.com/python-gitlab/python-gitlab/commit/e180f14309fa728e612ad6259c2e2c1f328a140c))
+
+- **deps**: Update dependency pytest to v7.1.3
+  ([`ec7f26c`](https://github.com/python-gitlab/python-gitlab/commit/ec7f26cd0f61a3cbadc3a1193c43b54d5b71c82b))
+
+- **deps**: Update dependency types-requests to v2.28.10
+  ([`5dde7d4`](https://github.com/python-gitlab/python-gitlab/commit/5dde7d41e48310ff70a4cef0b6bfa2df00fd8669))
+
+- **deps**: Update pre-commit hook commitizen-tools/commitizen to v2.32.2
+  ([`31ba64f`](https://github.com/python-gitlab/python-gitlab/commit/31ba64f2849ce85d434cd04ec7b837ca8f659e03))
+
+### Features
+
+- Add reset_approvals api
+  ([`88693ff`](https://github.com/python-gitlab/python-gitlab/commit/88693ff2d6f4eecf3c79d017df52738886e2d636))
+
+Added the newly added reset_approvals merge request api.
+
+Signed-off-by: Lucas Zampieri <lzampier@redhat.com>
+
+- Add support for deployment approval endpoint
+  ([`9c9eeb9`](https://github.com/python-gitlab/python-gitlab/commit/9c9eeb901b1f3acd3fb0c4f24014ae2ed7c975ec))
+
+Add support for the deployment approval endpoint[1]
+
+[1] https://docs.gitlab.com/ee/api/deployments.html#approve-or-reject-a-blocked-deployment Closes:
+  #2253
+
+
+## v3.9.0 (2022-08-28)
+
+### Chores
+
+- Fix issue if only run test_gitlab.py func test
+  ([`98f1956`](https://github.com/python-gitlab/python-gitlab/commit/98f19564c2a9feb108845d33bf3631fa219e51c6))
+
+Make it so can run just the test_gitlab.py functional test.
+
+For example: $ tox -e api_func_v4 -- -k test_gitlab.py
+
+- Only check for our UserWarning
+  ([`bd4dfb4`](https://github.com/python-gitlab/python-gitlab/commit/bd4dfb4729377bf64c552ef6052095aa0b5658b8))
+
+The GitHub CI is showing a ResourceWarning, causing our test to fail.
+
+Update test to only look for our UserWarning which should not appear.
+
+What was seen when debugging the GitHub CI: {message: ResourceWarning( "unclosed <socket.socket
+  fd=12, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=6, laddr=('127.0.0.1',
+  50862), raddr=('127.0.0.1', 8080)>" ), category: 'ResourceWarning', filename:
+  '/home/runner/work/python-gitlab/python-gitlab/.tox/api_func_v4/lib/python3.10/site-packages/urllib3/poolmanager.py',
+  lineno: 271, line: None }
+
+- **ci**: Make pytest annotations work
+  ([`f67514e`](https://github.com/python-gitlab/python-gitlab/commit/f67514e5ffdbe0141b91c88366ff5233e0293ca2))
+
+- **deps**: Update dependency commitizen to v2.31.0
+  ([`4ff0894`](https://github.com/python-gitlab/python-gitlab/commit/4ff0894870977f07657e80bfaa06387f2af87d10))
+
+- **deps**: Update dependency commitizen to v2.32.1
+  ([`9787c5c`](https://github.com/python-gitlab/python-gitlab/commit/9787c5cf01a518164b5951ec739abb1d410ff64c))
+
+- **deps**: Update dependency types-requests to v2.28.8
+  ([`8e5b86f`](https://github.com/python-gitlab/python-gitlab/commit/8e5b86fcc72bf30749228519f1b4a6e29a8dbbe9))
+
+- **deps**: Update dependency types-requests to v2.28.9
+  ([`be932f6`](https://github.com/python-gitlab/python-gitlab/commit/be932f6dde5f47fb3d30e654b82563cd719ae8ce))
+
+- **deps**: Update dependency types-setuptools to v64
+  ([`4c97f26`](https://github.com/python-gitlab/python-gitlab/commit/4c97f26287cc947ab5ee228a5862f2a20535d2ae))
+
+- **deps**: Update pre-commit hook commitizen-tools/commitizen to v2.31.0
+  ([`71d37d9`](https://github.com/python-gitlab/python-gitlab/commit/71d37d98721c0813b096124ed2ccf5487ab463b9))
+
+- **deps**: Update pre-commit hook commitizen-tools/commitizen to v2.32.1
+  ([`cdd6efe`](https://github.com/python-gitlab/python-gitlab/commit/cdd6efef596a1409d6d8a9ea13e04c943b8c4b6a))
+
+- **deps**: Update pre-commit hook pycqa/flake8 to v5
+  ([`835d884`](https://github.com/python-gitlab/python-gitlab/commit/835d884e702f1ee48575b3154136f1ef4b2f2ff2))
+
+### Features
+
+- Add support for merge_base API
+  ([`dd4fbd5`](https://github.com/python-gitlab/python-gitlab/commit/dd4fbd5e43adbbc502624a8de0d30925d798dec0))
+
+
+## v3.8.1 (2022-08-10)
+
+### Bug Fixes
+
+- **client**: Do not assume user attrs returned for auth()
+  ([`a07547c`](https://github.com/python-gitlab/python-gitlab/commit/a07547cba981380935966dff2c87c2c27d6b18d9))
+
+This is mostly relevant for people mocking the API in tests.
+
+### Chores
+
+- Add license badge to readme
+  ([`9aecc9e`](https://github.com/python-gitlab/python-gitlab/commit/9aecc9e5ae1e2e254b8a27283a0744fe6fd05fb6))
+
+- Consolidate license and authors
+  ([`366665e`](https://github.com/python-gitlab/python-gitlab/commit/366665e89045eb24d47f730e2a5dea6229839e20))
+
+- Remove broad Exception catching from `config.py`
+  ([`0abc90b`](https://github.com/python-gitlab/python-gitlab/commit/0abc90b7b456d75869869618097f8fcb0f0d9e8d))
+
+Change "except Exception:" catching to more granular exceptions.
+
+A step in enabling the "broad-except" check in pylint.
+
+- **deps**: Update dependency commitizen to v2.29.5
+  ([`181390a`](https://github.com/python-gitlab/python-gitlab/commit/181390a4e07e3c62b86ade11d9815d36440f5817))
+
+- **deps**: Update dependency flake8 to v5.0.4
+  ([`50a4fec`](https://github.com/python-gitlab/python-gitlab/commit/50a4feca96210e890d8ff824c2c6bf3d57f21799))
+
+- **deps**: Update dependency sphinx to v5
+  ([`3f3396e`](https://github.com/python-gitlab/python-gitlab/commit/3f3396ee383c8e6f2deeb286f04184a67edb6d1d))
+
+
+## v3.8.0 (2022-08-04)
+
+### Bug Fixes
+
+- Optionally keep user-provided base URL for pagination
+  ([#2149](https://github.com/python-gitlab/python-gitlab/pull/2149),
+  [`e2ea8b8`](https://github.com/python-gitlab/python-gitlab/commit/e2ea8b89a7b0aebdb1eb3b99196d7c0034076df8))
+
+- **client**: Ensure encoded query params are never duplicated
+  ([`1398426`](https://github.com/python-gitlab/python-gitlab/commit/1398426cd748fdf492fe6184b03ac2fcb7e4fd6e))
+
+### Chores
+
+- Change `_repr_attr` for Project to be `path_with_namespace`
+  ([`7cccefe`](https://github.com/python-gitlab/python-gitlab/commit/7cccefe6da0e90391953734d95debab2fe07ea49))
+
+Previously `_repr_attr` was `path` but that only gives the basename of the path. So
+  https://gitlab.com/gitlab-org/gitlab would only show "gitlab". Using `path_with_namespace` it will
+  now show "gitlab-org/gitlab"
+
+- Enable mypy check `disallow_any_generics`
+  ([`24d17b4`](https://github.com/python-gitlab/python-gitlab/commit/24d17b43da16dd11ab37b2cee561d9392c90f32e))
+
+- Enable mypy check `no_implicit_optional`
+  ([`64b208e`](https://github.com/python-gitlab/python-gitlab/commit/64b208e0e91540af2b645da595f0ef79ee7522e1))
+
+- Enable mypy check `warn_return_any`
+  ([`76ec4b4`](https://github.com/python-gitlab/python-gitlab/commit/76ec4b481fa931ea36a195ac474812c11babef7b))
+
+Update code so that the `warn_return_any` check passes.
+
+- Make code PEP597 compliant
+  ([`433dba0`](https://github.com/python-gitlab/python-gitlab/commit/433dba02e0d4462ae84a73d8699fe7f3e07aa410))
+
+Use `encoding="utf-8"` in `open()` and open-like functions.
+
+https://peps.python.org/pep-0597/
+
+- Use `urlunparse` instead of string replace
+  ([`6d1b62d`](https://github.com/python-gitlab/python-gitlab/commit/6d1b62d4b248c4c021a59cd234c3a2b19e6fad07))
+
+Use the `urlunparse()` function to reconstruct the URL without the query parameters.
+
+- **ci**: Bump semantic-release for fixed commit parser
+  ([`1e063ae`](https://github.com/python-gitlab/python-gitlab/commit/1e063ae1c4763c176be3c5e92da4ffc61cb5d415))
+
+- **clusters**: Deprecate clusters support
+  ([`b46b379`](https://github.com/python-gitlab/python-gitlab/commit/b46b3791707ac76d501d6b7b829d1370925fd614))
+
+Cluster support was deprecated in GitLab 14.5 [1]. And disabled by default in GitLab 15.0 [2]
+
+* Update docs to mark clusters as deprecated * Remove testing of clusters
+
+[1] https://docs.gitlab.com/ee/api/project_clusters.html [2]
+  https://gitlab.com/groups/gitlab-org/configure/-/epics/8
+
+- **deps**: Update dependency commitizen to v2.29.2
+  ([`30274ea`](https://github.com/python-gitlab/python-gitlab/commit/30274ead81205946a5a7560e592f346075035e0e))
+
+- **deps**: Update dependency flake8 to v5
+  ([`cdc384b`](https://github.com/python-gitlab/python-gitlab/commit/cdc384b8a2096e31aff12ea98383e2b1456c5731))
+
+- **deps**: Update dependency types-requests to v2.28.6
+  ([`54dd4c3`](https://github.com/python-gitlab/python-gitlab/commit/54dd4c3f857f82aa8781b0daf22fa2dd3c60c2c4))
+
+- **deps**: Update pre-commit hook commitizen-tools/commitizen to v2.29.2
+  ([`4988c02`](https://github.com/python-gitlab/python-gitlab/commit/4988c029e0dda89ff43375d1cd2f407abdbe3dc7))
+
+- **topics**: 'title' is required when creating a topic
+  ([`271f688`](https://github.com/python-gitlab/python-gitlab/commit/271f6880dbb15b56305efc1fc73924ac26fb97ad))
+
+In GitLab >= 15.0 `title` is required when creating a topic.
+
+### Documentation
+
+- Describe self-revoking personal access tokens
+  ([`5ea48fc`](https://github.com/python-gitlab/python-gitlab/commit/5ea48fc3c28f872dd1184957a6f2385da075281c))
+
+### Features
+
+- Support downloading archive subpaths
+  ([`cadb0e5`](https://github.com/python-gitlab/python-gitlab/commit/cadb0e55347cdac149e49f611c99b9d53a105520))
+
+- **client**: Warn user on misconfigured URL in `auth()`
+  ([`0040b43`](https://github.com/python-gitlab/python-gitlab/commit/0040b4337bae815cfe1a06f8371a7a720146f271))
+
+### Refactoring
+
+- **client**: Factor out URL check into a helper
+  ([`af21a18`](https://github.com/python-gitlab/python-gitlab/commit/af21a1856aa904f331859983493fe966d5a2969b))
+
+- **client**: Remove handling for incorrect link header
+  ([`77c04b1`](https://github.com/python-gitlab/python-gitlab/commit/77c04b1acb2815290bcd6f50c37d75329409e9d3))
+
+This was a quirk only present in GitLab 13.0 and fixed with 13.1. See
+  https://gitlab.com/gitlab-org/gitlab/-/merge_requests/33714 and
+  https://gitlab.com/gitlab-org/gitlab/-/issues/218504 for more context.
+
+### Testing
+
+- Attempt to make functional test startup more reliable
+  ([`67508e8`](https://github.com/python-gitlab/python-gitlab/commit/67508e8100be18ce066016dcb8e39fa9f0c59e51))
+
+The functional tests have been erratic. Current theory is that we are starting the tests before the
+  GitLab container is fully up and running.
+
+* Add checking of the Health Check[1] endpoints. * Add a 20 second delay after we believe it is up
+  and running. * Increase timeout from 300 to 400 seconds
+
+[1] https://docs.gitlab.com/ee/user/admin_area/monitoring/health_check.html
+
+- **functional**: Bump GitLab docker image to 15.2.0-ee.0
+  ([`69014e9`](https://github.com/python-gitlab/python-gitlab/commit/69014e9be3a781be6742478af820ea097d004791))
+
+Use the GitLab docker image 15.2.0-ee.0 in the functional testing.
+
+- **unit**: Reproduce duplicate encoded query params
+  ([`6f71c66`](https://github.com/python-gitlab/python-gitlab/commit/6f71c663a302b20632558b4c94be428ba831ee7f))
+
+
+## v3.7.0 (2022-07-28)
+
+### Bug Fixes
+
+- Add `get_all` param (and `--get-all`) to allow passing `all` to API
+  ([`7c71d5d`](https://github.com/python-gitlab/python-gitlab/commit/7c71d5db1199164b3fa9958e3c3bc6ec96efc78d))
+
+- Enable epic notes
+  ([`5fc3216`](https://github.com/python-gitlab/python-gitlab/commit/5fc3216788342a2325662644b42e8c249b655ded))
+
+Add the notes attribute to GroupEpic
+
+- Ensure path elements are escaped
+  ([`5d9c198`](https://github.com/python-gitlab/python-gitlab/commit/5d9c198769b00c8e7661e62aaf5f930ed32ef829))
+
+Ensure the path elements that are passed to the server are escaped. For example a "/" will be
+  changed to "%2F"
+
+Closes: #2116
+
+- Results returned by `attributes` property to show updates
+  ([`e5affc8`](https://github.com/python-gitlab/python-gitlab/commit/e5affc8749797293c1373c6af96334f194875038))
+
+Previously the `attributes` method would show the original values in a Gitlab Object even if they
+  had been updated. Correct this so that the updated value will be returned.
+
+Also use copy.deepcopy() to ensure that modifying the dictionary returned can not also modify the
+  object.
+
+- Support array types for most resources
+  ([`d9126cd`](https://github.com/python-gitlab/python-gitlab/commit/d9126cd802dd3cfe529fa940300113c4ead3054b))
+
+- Use the [] after key names for array variables in `params`
+  ([`1af44ce`](https://github.com/python-gitlab/python-gitlab/commit/1af44ce8761e6ee8a9467a3e192f6c4d19e5cefe))
+
+1. If a value is of type ArrayAttribute then append '[]' to the name of the value for query
+  parameters (`params`).
+
+This is step 3 in a series of steps of our goal to add full support for the GitLab API data
+  types[1]: * array * hash * array of hashes
+
+Step one was: commit 5127b1594c00c7364e9af15e42d2e2f2d909449b Step two was: commit
+  a57334f1930752c70ea15847a39324fa94042460
+
+Fixes: #1698
+
+[1] https://docs.gitlab.com/ee/api/#encoding-api-parameters-of-array-and-hash-types
+
+- **cli**: Remove irrelevant MR approval rule list filters
+  ([`0daec5f`](https://github.com/python-gitlab/python-gitlab/commit/0daec5fa1428a56a6a927b133613e8b296248167))
+
+- **config**: Raise error when gitlab id provided but no config file found
+  ([`ac46c1c`](https://github.com/python-gitlab/python-gitlab/commit/ac46c1cb291c03ad14bc76f5f16c9f98f2a5a82d))
+
+- **config**: Raise error when gitlab id provided but no config section found
+  ([`1ef7018`](https://github.com/python-gitlab/python-gitlab/commit/1ef70188da1e29cd8ba95bf58c994ba7dd3010c5))
+
+- **runners**: Fix listing for /runners/all
+  ([`c6dd57c`](https://github.com/python-gitlab/python-gitlab/commit/c6dd57c56e92abb6184badf4708f5f5e65c6d582))
+
+### Chores
+
+- Add a `lazy` boolean attribute to `RESTObject`
+  ([`a7e8cfb`](https://github.com/python-gitlab/python-gitlab/commit/a7e8cfbae8e53d2c4b1fb75d57d42f00db8abd81))
+
+This can be used to tell if a `RESTObject` was created using `lazy=True`.
+
+Add a message to the `AttributeError` if attribute access fails for an instance created with
+  `lazy=True`.
+
+- Change name of API functional test to `api_func_v4`
+  ([`8cf5cd9`](https://github.com/python-gitlab/python-gitlab/commit/8cf5cd935cdeaf36a6877661c8dfb0be6c69f587))
+
+The CLI test is `cli_func_v4` and using `api_func_v4` matches with that naming convention.
+
+- Enable mypy check `strict_equality`
+  ([`a29cd6c`](https://github.com/python-gitlab/python-gitlab/commit/a29cd6ce1ff7fa7f31a386cea3e02aa9ba3fb6c2))
+
+Enable the `mypy` `strict_equality` check.
+
+- Enable using GitLab EE in functional tests
+  ([`17c01ea`](https://github.com/python-gitlab/python-gitlab/commit/17c01ea55806c722523f2f9aef0175455ec942c5))
+
+Enable using GitLab Enterprise Edition (EE) in the functional tests. This will allow us to add
+  functional tests for EE only features in the functional tests.
+
+- Fix misspelling
+  ([`2d08fc8`](https://github.com/python-gitlab/python-gitlab/commit/2d08fc89fb67de25ad41f64c86a9b8e96e4c261a))
+
+- Fixtures: after delete() wait to verify deleted
+  ([`1f73b6b`](https://github.com/python-gitlab/python-gitlab/commit/1f73b6b20f08a0fe4ce4cf9195702a03656a54e1))
+
+In our fixtures that create: - groups - project merge requests - projects - users
+
+They delete the created objects after use. Now wait to ensure the objects are deleted before
+  continuing as having unexpected objects existing can impact some of our tests.
+
+- Make reset_gitlab() better
+  ([`d87d6b1`](https://github.com/python-gitlab/python-gitlab/commit/d87d6b12fd3d73875559924cda3fd4b20402d336))
+
+Saw issues in the CI where reset_gitlab() would fail. It would fail to delete the group that is
+  created when GitLab starts up. Extending the timeout didn't fix the issue.
+
+Changed the code to use the new `helpers.safe_delete()` function. Which will delete the resource and
+  then make sure it is deleted before returning.
+
+Also added some logging functionality that can be seen if logging is turned on in pytest.
+
+- Revert "test(functional): simplify token creation"
+  ([`4b798fc`](https://github.com/python-gitlab/python-gitlab/commit/4b798fc2fdc44b73790c493c329147013464de14))
+
+This reverts commit 67ab24fe5ae10a9f8cc9122b1a08848e8927635d.
+
+- Simplify multi-nested try blocks
+  ([`e734470`](https://github.com/python-gitlab/python-gitlab/commit/e7344709d931e2b254d225d77ca1474bc69971f8))
+
+Instead of have a multi-nested series of try blocks. Convert it to a more readable series of `if`
+  statements.
+
+- **authors**: Fix email and do the ABC
+  ([`9833632`](https://github.com/python-gitlab/python-gitlab/commit/98336320a66d1859ba73e084a5e86edc3aa1643c))
+
+- **ci_lint**: Add create attributes
+  ([`6e1342f`](https://github.com/python-gitlab/python-gitlab/commit/6e1342fc0b7cf740b25a939942ea02cdd18a9625))
+
+- **deps**: Update black to v22.6.0
+  ([`82bd596`](https://github.com/python-gitlab/python-gitlab/commit/82bd59673c5c66da0cfa3b24d58b627946fe2cc3))
+
+- **deps**: Update dependency commitizen to v2.28.0
+  ([`8703dd3`](https://github.com/python-gitlab/python-gitlab/commit/8703dd3c97f382920075e544b1b9d92fab401cc8))
+
+- **deps**: Update dependency commitizen to v2.29.0
+  ([`c365be1`](https://github.com/python-gitlab/python-gitlab/commit/c365be1b908c5e4fda445680c023607bdf6c6281))
+
+- **deps**: Update dependency mypy to v0.971
+  ([`7481d27`](https://github.com/python-gitlab/python-gitlab/commit/7481d271512eaa234315bcdbaf329026589bfda7))
+
+- **deps**: Update dependency pylint to v2.14.4
+  ([`2cee2d4`](https://github.com/python-gitlab/python-gitlab/commit/2cee2d4a86e76d3f63f3608ed6a92e64813613d3))
+
+- **deps**: Update dependency pylint to v2.14.5
+  ([`e153636`](https://github.com/python-gitlab/python-gitlab/commit/e153636d74a0a622b0cc18308aee665b3eca58a4))
+
+- **deps**: Update dependency requests to v2.28.1
+  ([`be33245`](https://github.com/python-gitlab/python-gitlab/commit/be3324597aa3f22b0692d3afa1df489f2709a73e))
+
+- **deps**: Update pre-commit hook commitizen-tools/commitizen to v2.28.0
+  ([`d238e1b`](https://github.com/python-gitlab/python-gitlab/commit/d238e1b464c98da86677934bf99b000843d36747))
+
+- **deps**: Update pre-commit hook commitizen-tools/commitizen to v2.29.0
+  ([`ad8d62a`](https://github.com/python-gitlab/python-gitlab/commit/ad8d62ae9612c173a749d413f7a84e5b8c0167cf))
+
+- **deps**: Update pre-commit hook pycqa/pylint to v2.14.4
+  ([`5cd39be`](https://github.com/python-gitlab/python-gitlab/commit/5cd39be000953907cdc2ce877a6bf267d601b707))
+
+- **deps**: Update pre-commit hook pycqa/pylint to v2.14.5
+  ([`c75a1d8`](https://github.com/python-gitlab/python-gitlab/commit/c75a1d860709e17a7c3324c5d85c7027733ea1e1))
+
+- **deps**: Update typing dependencies
+  ([`f2209a0`](https://github.com/python-gitlab/python-gitlab/commit/f2209a0ea084eaf7fbc89591ddfea138d99527a6))
+
+- **deps**: Update typing dependencies
+  ([`e772248`](https://github.com/python-gitlab/python-gitlab/commit/e77224818e63e818c10a7fad69f90e16d618bdf7))
+
+- **docs**: Convert tabs to spaces
+  ([`9ea5520`](https://github.com/python-gitlab/python-gitlab/commit/9ea5520cec8979000d7f5dbcc950f2250babea96))
+
+Some tabs snuck into the documentation. Convert them to 4-spaces.
+
+### Documentation
+
+- Describe fetching existing export status
+  ([`9c5b8d5`](https://github.com/python-gitlab/python-gitlab/commit/9c5b8d54745a58b9fe72ba535b7868d1510379c0))
+
+- Describe ROPC flow in place of password authentication
+  ([`91c17b7`](https://github.com/python-gitlab/python-gitlab/commit/91c17b704f51e9a06b241d549f9a07a19c286118))
+
+- Document CI Lint usage
+  ([`d5de4b1`](https://github.com/python-gitlab/python-gitlab/commit/d5de4b1fe38bedc07862bd9446dfd48b92cb078d))
+
+- Update return type of pushrules
+  ([`53cbecc`](https://github.com/python-gitlab/python-gitlab/commit/53cbeccd581318ce4ff6bec0acf3caf935bda0cf))
+
+Update the return type of pushrules to surround None with back-ticks to make it code-formatted.
+
+- **authors**: Add John
+  ([`e2afb84`](https://github.com/python-gitlab/python-gitlab/commit/e2afb84dc4a259e8f40b7cc83e56289983c7db47))
+
+- **cli**: Showcase use of token scopes
+  ([`4a6f8d6`](https://github.com/python-gitlab/python-gitlab/commit/4a6f8d67a94a3d104a24081ad1dbad5b2e3d9c3e))
+
+- **projects**: Document export with upload to URL
+  ([`03f5484`](https://github.com/python-gitlab/python-gitlab/commit/03f548453d84d99354aae7b638f5267e5d751c59))
+
+- **readme**: Remove redundant `-v` that breaks the command
+  ([`c523e18`](https://github.com/python-gitlab/python-gitlab/commit/c523e186cc48f6bcac5245e3109b50a3852d16ef))
+
+- **users**: Add docs about listing a user's projects
+  ([`065a1a5`](https://github.com/python-gitlab/python-gitlab/commit/065a1a5a32d34286df44800084285b30b934f911))
+
+Add docs about listing a user's projects.
+
+Update docs on the membership API to update the URL to the upstream docs and also add a note that it
+  requires Administrator access to use.
+
+### Features
+
+- Add 'merge_pipelines_enabled' project attribute
+  ([`fc33c93`](https://github.com/python-gitlab/python-gitlab/commit/fc33c934d54fb94451bd9b9ad65645c9c3d6fe2e))
+
+Boolean. Enable or disable merge pipelines.
+
+See: https://docs.gitlab.com/ee/api/projects.html#edit-project
+  https://docs.gitlab.com/ee/ci/pipelines/merged_results_pipelines.html
+
+- Add `asdict()` and `to_json()` methods to Gitlab Objects
+  ([`08ac071`](https://github.com/python-gitlab/python-gitlab/commit/08ac071abcbc28af04c0fa655576e25edbdaa4e2))
+
+Add an `asdict()` method that returns a dictionary representation copy of the Gitlab Object. This is
+  a copy and changes made to it will have no impact on the Gitlab Object.
+
+The `asdict()` method name was chosen as both the `dataclasses` and `attrs` libraries have an
+  `asdict()` function which has the similar purpose of creating a dictionary represenation of an
+  object.
+
+Also add a `to_json()` method that returns a JSON string representation of the object.
+
+Closes: #1116
+
+- Add support for filtering jobs by scope
+  ([`0e1c0dd`](https://github.com/python-gitlab/python-gitlab/commit/0e1c0dd795886ae4741136e64c33850b164084a1))
+
+See: 'scope' here:
+
+https://docs.gitlab.com/ee/api/jobs.html#list-project-jobs
+
+- Add support for group and project invitations API
+  ([`7afd340`](https://github.com/python-gitlab/python-gitlab/commit/7afd34027a26b5238a979e3303d8e5d8a0320a07))
+
+- Add support for group push rules
+  ([`b5cdc09`](https://github.com/python-gitlab/python-gitlab/commit/b5cdc097005c8a48a16e793a69c343198b14e035))
+
+Add the GroupPushRules and GroupPushRulesManager classes.
+
+Closes: #1259
+
+- Add support for iterations API
+  ([`194ee01`](https://github.com/python-gitlab/python-gitlab/commit/194ee0100c2868c1a9afb161c15f3145efb01c7c))
+
+- Allow sort/ordering for project releases
+  ([`b1dd284`](https://github.com/python-gitlab/python-gitlab/commit/b1dd284066b4b94482b9d41310ac48b75bcddfee))
+
+See: https://docs.gitlab.com/ee/api/releases/#list-releases
+
+- Support validating CI lint results
+  ([`3b1ede4`](https://github.com/python-gitlab/python-gitlab/commit/3b1ede4a27cd730982d4c579437c5c689a8799e5))
+
+- **api**: Add support for `get` for a MR approval rule
+  ([`89c18c6`](https://github.com/python-gitlab/python-gitlab/commit/89c18c6255ec912db319f73f141b47ace87a713b))
+
+In GitLab 14.10 they added support to get a single merge request approval rule [1]
+
+Add support for it to ProjectMergeRequestApprovalRuleManager
+
+[1]
+  https://docs.gitlab.com/ee/api/merge_request_approvals.html#get-a-single-merge-request-level-rule
+
+- **api**: Add support for instance-level registry repositories
+  ([`284d739`](https://github.com/python-gitlab/python-gitlab/commit/284d73950ad5cf5dfbdec2f91152ed13931bd0ee))
+
+- **cli**: Add a custom help formatter
+  ([`005ba93`](https://github.com/python-gitlab/python-gitlab/commit/005ba93074d391f818c39e46390723a0d0d16098))
+
+Add a custom argparse help formatter that overrides the output format to list items vertically.
+
+The formatter is derived from argparse.HelpFormatter with minimal changes.
+
+Co-authored-by: John Villalovos <john@sodarock.com>
+
+Co-authored-by: Nejc Habjan <nejc.habjan@siemens.com>
+
+- **cli**: Add support for global CI lint
+  ([`3f67c4b`](https://github.com/python-gitlab/python-gitlab/commit/3f67c4b0fb0b9a39c8b93529a05b1541fcebcabe))
+
+- **groups**: Add support for group-level registry repositories
+  ([`70148c6`](https://github.com/python-gitlab/python-gitlab/commit/70148c62a3aba16dd8a9c29f15ed16e77c01a247))
+
+- **groups**: Add support for shared projects API
+  ([`66461ba`](https://github.com/python-gitlab/python-gitlab/commit/66461ba519a85bfbd3cba284a0c8de11a3ac7cde))
+
+- **issues**: Add support for issue reorder API
+  ([`8703324`](https://github.com/python-gitlab/python-gitlab/commit/8703324dc21a30757e15e504b7d20472f25d2ab9))
+
+- **namespaces**: Add support for namespace existence API
+  ([`4882cb2`](https://github.com/python-gitlab/python-gitlab/commit/4882cb22f55c41d8495840110be2d338b5545a04))
+
+- **objects**: Add Project CI Lint support
+  ([`b213dd3`](https://github.com/python-gitlab/python-gitlab/commit/b213dd379a4108ab32181b9d3700d2526d950916))
+
+Add support for validating a project's CI configuration [1]
+
+[1] https://docs.gitlab.com/ee/api/lint.html
+
+- **projects**: Add support for project restore API
+  ([`4794ecc`](https://github.com/python-gitlab/python-gitlab/commit/4794ecc45d7aa08785c622918d08bb046e7359ae))
+
+### Refactoring
+
+- Migrate services to integrations
+  ([`a428051`](https://github.com/python-gitlab/python-gitlab/commit/a4280514546cc6e39da91d1671921b74b56c3283))
+
+- **objects**: Move ci lint to separate file
+  ([`6491f1b`](https://github.com/python-gitlab/python-gitlab/commit/6491f1bbb68ffe04c719eb9d326b7ca3e78eba84))
+
+- **test-projects**: Apply suggestions and use fixtures
+  ([`a51f848`](https://github.com/python-gitlab/python-gitlab/commit/a51f848db4204b2f37ae96fd235ae33cb7c2fe98))
+
+- **test-projects**: Remove test_restore_project
+  ([`9be0875`](https://github.com/python-gitlab/python-gitlab/commit/9be0875c3793324b4c4dde29519ee62b39a8cc18))
+
+### Testing
+
+- Add more tests for container registries
+  ([`f6b6e18`](https://github.com/python-gitlab/python-gitlab/commit/f6b6e18f96f4cdf67c8c53ae79e6a8259dcce9ee))
+
+- Add test to show issue fixed
+  ([`75bec7d`](https://github.com/python-gitlab/python-gitlab/commit/75bec7d543dd740c50452b21b0b4509377cd40ce))
+
+https://github.com/python-gitlab/python-gitlab/issues/1698 has been fixed. Add test to show that.
+
+- Allow `podman` users to run functional tests
+  ([`ff215b7`](https://github.com/python-gitlab/python-gitlab/commit/ff215b7056ce2adf2b85ecc1a6c3227d2b1a5277))
+
+Users of `podman` will likely have `DOCKER_HOST` set to something like
+  `unix:///run/user/1000/podman/podman.sock`
+
+Pass this environment variable so that it will be used during the functional tests.
+
+- Always ensure clean config environment
+  ([`8d4f13b`](https://github.com/python-gitlab/python-gitlab/commit/8d4f13b192afd5d4610eeaf2bbea71c3b6a25964))
+
+- Fix broken test if user had config files
+  ([`864fc12`](https://github.com/python-gitlab/python-gitlab/commit/864fc1218e6366b9c1d8b1b3832e06049c238d8c))
+
+Use `monkeypatch` to ensure that no config files are reported for the test.
+
+Closes: #2172
+
+- **api_func_v4**: Catch deprecation warning for `gl.lint()`
+  ([`95fe924`](https://github.com/python-gitlab/python-gitlab/commit/95fe9247fcc9cba65c4afef934f816be06027ff5))
+
+Catch the deprecation warning for the call to `gl.lint()`, so it won't show up in the log.
+
+- **cli**: Add tests for token scopes
+  ([`263fe3d`](https://github.com/python-gitlab/python-gitlab/commit/263fe3d24836b34dccdcee0221bd417e0b74fb2e))
+
+- **ee**: Add an EE specific test
+  ([`10987b3`](https://github.com/python-gitlab/python-gitlab/commit/10987b3089d4fe218dd2116dd871e0a070db3f7f))
+
+- **functional**: Replace len() calls with list membership checks
+  ([`97e0eb9`](https://github.com/python-gitlab/python-gitlab/commit/97e0eb9267202052ed14882258dceca0f6c4afd7))
+
+- **functional**: Simplify token creation
+  ([`67ab24f`](https://github.com/python-gitlab/python-gitlab/commit/67ab24fe5ae10a9f8cc9122b1a08848e8927635d))
+
+- **functional**: Use both get_all and all in list() tests
+  ([`201298d`](https://github.com/python-gitlab/python-gitlab/commit/201298d7b5795b7d7338793da8033dc6c71d6572))
+
+- **projects**: Add unit tests for projects
+  ([`67942f0`](https://github.com/python-gitlab/python-gitlab/commit/67942f0d46b7d445f28f80d3f57aa91eeea97a24))
+
+
+## v3.6.0 (2022-06-28)
+
+### Bug Fixes
+
+- **base**: Do not fail repr() on lazy objects
+  ([`1efb123`](https://github.com/python-gitlab/python-gitlab/commit/1efb123f63eab57600228b75a1744f8787c16671))
+
+- **cli**: Fix project export download for CLI
+  ([`5d14867`](https://github.com/python-gitlab/python-gitlab/commit/5d1486785793b02038ac6f527219801744ee888b))
+
+Since ac1c619cae6481833f5df91862624bf0380fef67 we delete parent arg keys from the args dict so this
+  has been trying to access the wrong attribute.
+
+- **cli**: Project-merge-request-approval-rule
+  ([`15a242c`](https://github.com/python-gitlab/python-gitlab/commit/15a242c3303759b77b380c5b3ff9d1e0bf2d800c))
+
+Using the CLI the command: gitlab project-merge-request-approval-rule list --mr-iid 1 --project-id
+  foo/bar
+
+Would raise an exception. This was due to the fact that `_id_attr` and `_repr_attr` were set for
+  keys which are not returned in the response.
+
+Add a unit test which shows the `repr` function now works. Before it did not.
+
+This is an EE feature so we can't functional test it.
+
+Closes: #2065
+
+### Chores
+
+- Add link to Commitizen in Github workflow
+  ([`d08d07d`](https://github.com/python-gitlab/python-gitlab/commit/d08d07deefae345397fc30280c4f790c7e61cbe2))
+
+Add a link to the Commitizen website in the Github workflow. Hopefully this will help people when
+  their job fails.
+
+- Bump mypy pre-commit hook
+  ([`0bbcad7`](https://github.com/python-gitlab/python-gitlab/commit/0bbcad7612f60f7c7b816c06a244ad8db9da68d9))
+
+- Correct ModuleNotFoundError() arguments
+  ([`0b7933c`](https://github.com/python-gitlab/python-gitlab/commit/0b7933c5632c2f81c89f9a97e814badf65d1eb38))
+
+Previously in commit 233b79ed442aac66faf9eb4b0087ea126d6dffc5 I had used the `name` argument for
+  `ModuleNotFoundError()`. This basically is the equivalent of not passing any message to
+  `ModuleNotFoundError()`. So when the exception was raised it wasn't very helpful.
+
+Correct that and add a unit-test that shows we get the message we expect.
+
+- Enable 'consider-using-sys-exit' pylint check
+  ([`0afcc3e`](https://github.com/python-gitlab/python-gitlab/commit/0afcc3eca4798801ff3635b05b871e025078ef31))
+
+Enable the 'consider-using-sys-exit' pylint check and fix errors raised.
+
+- Enable pylint check "raise-missing-from"
+  ([`1a2781e`](https://github.com/python-gitlab/python-gitlab/commit/1a2781e477471626e2b00129bef5169be9c7cc06))
+
+Enable the pylint check "raise-missing-from" and fix errors detected.
+
+- Enable pylint check: "attribute-defined-outside-init"
+  ([`d6870a9`](https://github.com/python-gitlab/python-gitlab/commit/d6870a981259ee44c64210a756b63dc19a6f3957))
+
+Enable the pylint check: "attribute-defined-outside-init" and fix errors detected.
+
+- Enable pylint check: "no-else-return"
+  ([`d0b0811`](https://github.com/python-gitlab/python-gitlab/commit/d0b0811211f69f08436dcf7617c46617fe5c0b8b))
+
+Enable the pylint check "no-else-return" and fix the errors detected.
+
+- Enable pylint check: "no-self-use"
+  ([`80aadaf`](https://github.com/python-gitlab/python-gitlab/commit/80aadaf4262016a8181b5150ca7e17c8139c15fa))
+
+Enable the pylint check "no-self-use" and fix the errors detected.
+
+- Enable pylint check: "redefined-outer-name",
+  ([`1324ce1`](https://github.com/python-gitlab/python-gitlab/commit/1324ce1a439befb4620953a4df1f70b74bf70cbd))
+
+Enable the pylint check "redefined-outer-name" and fix the errors detected.
+
+- Enable pylint checks
+  ([`1e89164`](https://github.com/python-gitlab/python-gitlab/commit/1e8916438f7c4f67bd7745103b870d84f6ba2d01))
+
+Enable the pylint checks: * unnecessary-pass * unspecified-encoding
+
+Update code to resolve errors found
+
+- Enable pylint checks which require no changes
+  ([`50fdbc4`](https://github.com/python-gitlab/python-gitlab/commit/50fdbc474c524188952e0ef7c02b0bd92df82357))
+
+Enabled the pylint checks that don't require any code changes. Previously these checks were
+  disabled.
+
+- Fix issue found with pylint==2.14.3
+  ([`eeab035`](https://github.com/python-gitlab/python-gitlab/commit/eeab035ab715e088af73ada00e0a3b0c03527187))
+
+A new error was reported when running pylint==2.14.3: gitlab/client.py:488:0: W1404: Implicit string
+  concatenation found in call (implicit-str-concat)
+
+Fixed this issue.
+
+- Have `EncodedId` creation always return `EncodedId`
+  ([`a1a246f`](https://github.com/python-gitlab/python-gitlab/commit/a1a246fbfcf530732249a263ee42757a862181aa))
+
+There is no reason to return an `int` as we can always return a `str` version of the `int`
+
+Change `EncodedId` to always return an `EncodedId`. This removes the need to have `mypy` ignore the
+  error raised.
+
+- Move `RequiredOptional` to the `gitlab.types` module
+  ([`7d26530`](https://github.com/python-gitlab/python-gitlab/commit/7d26530640eb406479f1604cb64748d278081864))
+
+By having `RequiredOptional` in the `gitlab.base` module it makes it difficult with circular
+  imports. Move it to the `gitlab.types` module which has no dependencies on any other gitlab
+  module.
+
+- Move `utils._validate_attrs` inside `types.RequiredOptional`
+  ([`9d629bb`](https://github.com/python-gitlab/python-gitlab/commit/9d629bb97af1e14ce8eb4679092de2393e1e3a05))
+
+Move the `validate_attrs` function to be inside the `RequiredOptional` class. It makes sense for it
+  to be part of the class as it is working on data related to the class.
+
+- Patch sphinx for explicit re-exports
+  ([`06871ee`](https://github.com/python-gitlab/python-gitlab/commit/06871ee05b79621f0a6fea47243783df105f64d6))
+
+- Remove use of '%' string formatter in `gitlab/utils.py`
+  ([`0c5a121`](https://github.com/python-gitlab/python-gitlab/commit/0c5a1213ba3bb3ec4ed5874db4588d21969e9e80))
+
+Replace usage with f-string
+
+- Rename `__call__()` to `run()` in GitlabCLI
+  ([`6189437`](https://github.com/python-gitlab/python-gitlab/commit/6189437d2c8d18f6c7d72aa7743abd6d36fb4efa))
+
+Less confusing to have it be a normal method.
+
+- Rename `whaction` and `action` to `resource_action` in CLI
+  ([`fb3f28a`](https://github.com/python-gitlab/python-gitlab/commit/fb3f28a053f0dcf0a110bb8b6fd11696b4ba3dd9))
+
+Rename the variables `whaction` and `action` to `resource_action` to improve code-readability.
+
+- Rename `what` to `gitlab_resource`
+  ([`c86e471`](https://github.com/python-gitlab/python-gitlab/commit/c86e471dead930468172f4b7439ea6fa207f12e8))
+
+Naming a variable `what` makes it difficult to understand what it is used for.
+
+Rename it to `gitlab_resource` as that is what is being stored.
+
+The Gitlab documentation talks about them being resources:
+  https://docs.gitlab.com/ee/api/api_resources.html
+
+This will improve code readability.
+
+- Require f-strings
+  ([`96e994d`](https://github.com/python-gitlab/python-gitlab/commit/96e994d9c5c1abd11b059fe9f0eec7dac53d2f3a))
+
+We previously converted all string formatting to use f-strings. Enable pylint check to enforce this.
+
+- Update type-hints return signature for GetWithoutIdMixin methods
+  ([`aa972d4`](https://github.com/python-gitlab/python-gitlab/commit/aa972d49c57f2ebc983d2de1cfb8d18924af6734))
+
+Commit f0152dc3cc9a42aa4dc3c0014b4c29381e9b39d6 removed situation where `get()` in a
+  `GetWithoutIdMixin` based class could return `None`
+
+Update the type-hints to no longer return `Optional` AKA `None`
+
+- Use multiple processors when running PyLint
+  ([`7f2240f`](https://github.com/python-gitlab/python-gitlab/commit/7f2240f1b9231e8b856706952ec84234177a495b))
+
+Use multiple processors when running PyLint. On my system it took about 10.3 seconds to run PyLint
+  before this change. After this change it takes about 5.8 seconds to run PyLint.
+
+- **ci**: Increase timeout for docker container to come online
+  ([`bda020b`](https://github.com/python-gitlab/python-gitlab/commit/bda020bf5f86d20253f39698c3bb32f8d156de60))
+
+Have been seeing timeout issues more and more. Increase timeout from 200 seconds to 300 seconds (5
+  minutes).
+
+- **ci**: Pin 3.11 to beta.1
+  ([`7119f2d`](https://github.com/python-gitlab/python-gitlab/commit/7119f2d228115fe83ab23612e189c9986bb9fd1b))
+
+- **cli**: Ignore coverage on exceptions triggering cli.die
+  ([`98ccc3c`](https://github.com/python-gitlab/python-gitlab/commit/98ccc3c2622a3cdb24797fd8790e921f5f2c1e6a))
+
+- **cli**: Rename "object" to "GitLab resource"
+  ([`62e64a6`](https://github.com/python-gitlab/python-gitlab/commit/62e64a66dab4b3704d80d19a5dbc68b025b18e3c))
+
+Make the parser name more user friendly by renaming from generic "object" to "GitLab resource"
+
+- **deps**: Ignore python-semantic-release updates
+  ([`f185b17`](https://github.com/python-gitlab/python-gitlab/commit/f185b17ff5aabedd32d3facd2a46ebf9069c9692))
+
+- **deps**: Update actions/setup-python action to v4
+  ([`77c1f03`](https://github.com/python-gitlab/python-gitlab/commit/77c1f0352adc8488041318e5dfd2fa98a5b5af62))
+
+- **deps**: Update dependency commitizen to v2.27.1
+  ([`456f9f1`](https://github.com/python-gitlab/python-gitlab/commit/456f9f14453f2090fdaf88734fe51112bf4e7fde))
+
+- **deps**: Update dependency mypy to v0.960
+  ([`8c016c7`](https://github.com/python-gitlab/python-gitlab/commit/8c016c7a53c543d07d16153039053bb370a6945b))
+
+- **deps**: Update dependency mypy to v0.961
+  ([`f117b2f`](https://github.com/python-gitlab/python-gitlab/commit/f117b2f92226a507a8adbb42023143dac0cc07fc))
+
+- **deps**: Update dependency pylint to v2.14.3
+  ([`9a16bb1`](https://github.com/python-gitlab/python-gitlab/commit/9a16bb158f3cb34a4c4cb7451127fbc7c96642e2))
+
+- **deps**: Update dependency requests to v2.28.0
+  ([`d361f4b`](https://github.com/python-gitlab/python-gitlab/commit/d361f4bd4ec066452a75cf04f64334234478bb02))
+
+- **deps**: Update pre-commit hook commitizen-tools/commitizen to v2.27.1
+  ([`22c5db4`](https://github.com/python-gitlab/python-gitlab/commit/22c5db4bcccf592f5cf7ea34c336208c21769896))
+
+- **deps**: Update pre-commit hook pycqa/pylint to v2.14.3
+  ([`d1fe838`](https://github.com/python-gitlab/python-gitlab/commit/d1fe838b65ccd1a68fb6301bbfd06cd19425a75c))
+
+- **deps**: Update typing dependencies
+  ([`acc5c39`](https://github.com/python-gitlab/python-gitlab/commit/acc5c3971f13029288dff2909692a0171f4a66f7))
+
+- **deps**: Update typing dependencies
+  ([`aebf9c8`](https://github.com/python-gitlab/python-gitlab/commit/aebf9c83a4cbf7cf4243cb9b44375ca31f9cc878))
+
+- **deps**: Update typing dependencies
+  ([`f3f79c1`](https://github.com/python-gitlab/python-gitlab/commit/f3f79c1d3afa923405b83dcea905fec213201452))
+
+- **docs**: Ignore nitpicky warnings
+  ([`1c3efb5`](https://github.com/python-gitlab/python-gitlab/commit/1c3efb50bb720a87b95307f4d6642e3b7f28f6f0))
+
+- **gitlab**: Fix implicit re-exports for mpypy
+  ([`981b844`](https://github.com/python-gitlab/python-gitlab/commit/981b8448dbadc63d70867dc069e33d4c4d1cfe95))
+
+- **mixins**: Remove None check as http_get always returns value
+  ([`f0152dc`](https://github.com/python-gitlab/python-gitlab/commit/f0152dc3cc9a42aa4dc3c0014b4c29381e9b39d6))
+
+- **workflows**: Explicitly use python-version
+  ([`eb14475`](https://github.com/python-gitlab/python-gitlab/commit/eb1447588dfbbdfe724fca9009ea5451061b5ff0))
+
+### Documentation
+
+- Documentation updates to reflect addition of mutually exclusive attributes
+  ([`24b720e`](https://github.com/python-gitlab/python-gitlab/commit/24b720e49636044f4be7e4d6e6ce3da341f2aeb8))
+
+- Drop deprecated setuptools build_sphinx
+  ([`048d66a`](https://github.com/python-gitlab/python-gitlab/commit/048d66af51cef385b22d223ed2a5cd30e2256417))
+
+- Use `as_list=False` or `all=True` in Getting started
+  ([`de8c6e8`](https://github.com/python-gitlab/python-gitlab/commit/de8c6e80af218d93ca167f8b5ff30319a2781d91))
+
+In the "Getting started with the API" section of the documentation, use either `as_list=False` or
+  `all=True` in the example usages of the `list()` method.
+
+Also add a warning about the fact that `list()` by default does not return all items.
+
+- **api**: Add separate section for advanced usage
+  ([`22ae101`](https://github.com/python-gitlab/python-gitlab/commit/22ae1016f39256b8e2ca02daae8b3c7130aeb8e6))
+
+- **api**: Document usage of head() methods
+  ([`f555bfb`](https://github.com/python-gitlab/python-gitlab/commit/f555bfb363779cc6c8f8036f6d6cfa302e15d4fe))
+
+- **api**: Fix incorrect docs for merge_request_approvals
+  ([#2094](https://github.com/python-gitlab/python-gitlab/pull/2094),
+  [`5583eaa`](https://github.com/python-gitlab/python-gitlab/commit/5583eaa108949386c66290fecef4d064f44b9e83))
+
+* docs(api): fix incorrect docs for merge_request_approvals
+
+The `set_approvers()` method is on the `ProjectApprovalManager` class. It is not part of the
+  `ProjectApproval` class.
+
+The docs were previously showing to call `set_approvers` using a `ProjectApproval` instance, which
+  would fail. Correct the documentation.
+
+This was pointed out by a question on the Gitter channel.
+
+Co-authored-by: Nejc Habjan <nejc.habjan@siemens.com>
+
+- **api**: Stop linking to python-requests.org
+  ([`49c7e83`](https://github.com/python-gitlab/python-gitlab/commit/49c7e83f768ee7a3fec19085a0fa0a67eadb12df))
+
+- **api-usage**: Add import os in example
+  ([`2194a44`](https://github.com/python-gitlab/python-gitlab/commit/2194a44be541e9d2c15d3118ba584a4a173927a2))
+
+- **ext**: Fix rendering for RequiredOptional dataclass
+  ([`4d431e5`](https://github.com/python-gitlab/python-gitlab/commit/4d431e5a6426d0fd60945c2d1ff00a00a0a95b6c))
+
+- **projects**: Document 404 gotcha with unactivated integrations
+  ([`522ecff`](https://github.com/python-gitlab/python-gitlab/commit/522ecffdb6f07e6c017139df4eb5d3fc42a585b7))
+
+- **projects**: Provide more detailed import examples
+  ([`8f8611a`](https://github.com/python-gitlab/python-gitlab/commit/8f8611a1263b8c19fd19ce4a904a310b0173b6bf))
+
+- **usage**: Refer to upsteam docs instead of custom attributes
+  ([`ae7d3b0`](https://github.com/python-gitlab/python-gitlab/commit/ae7d3b09352b2a1bd287f95d4587b04136c7a4ed))
+
+- **variables**: Instruct users to follow GitLab rules for values
+  ([`194b6be`](https://github.com/python-gitlab/python-gitlab/commit/194b6be7ccec019fefc04754f98b9ec920c29568))
+
+### Features
+
+- Add support for Protected Environments
+  ([`1dc9d0f`](https://github.com/python-gitlab/python-gitlab/commit/1dc9d0f91757eed9f28f0c7172654b9b2a730216))
+
+- https://docs.gitlab.com/ee/api/protected_environments.html -
+  https://github.com/python-gitlab/python-gitlab/issues/1130
+
+no write operation are implemented yet as I have no use case right now and am not sure how it should
+  be done
+
+- Support mutually exclusive attributes and consolidate validation to fix board lists
+  ([#2037](https://github.com/python-gitlab/python-gitlab/pull/2037),
+  [`3fa330c`](https://github.com/python-gitlab/python-gitlab/commit/3fa330cc341bbedb163ba757c7f6578d735c6efb))
+
+add exclusive tuple to RequiredOptional data class to support for mutually exclusive attributes
+
+consolidate _check_missing_create_attrs and _check_missing_update_attrs from mixins.py into
+  _validate_attrs in utils.py
+
+change _create_attrs in board list manager classes from required=('label_ld',) to
+  exclusive=('label_id','asignee_id','milestone_id')
+
+closes https://github.com/python-gitlab/python-gitlab/issues/1897
+
+- **api**: Convert gitlab.const to Enums
+  ([`c3c6086`](https://github.com/python-gitlab/python-gitlab/commit/c3c6086c548c03090ccf3f59410ca3e6b7999791))
+
+This allows accessing the elements by value, i.e.:
+
+import gitlab.const gitlab.const.AccessLevel(20)
+
+- **api**: Implement HEAD method
+  ([`90635a7`](https://github.com/python-gitlab/python-gitlab/commit/90635a7db3c9748745471d2282260418e31c7797))
+
+- **api**: Support head() method for get and list endpoints
+  ([`ce9216c`](https://github.com/python-gitlab/python-gitlab/commit/ce9216ccc542d834be7f29647c7ee98c2ca5bb01))
+
+- **client**: Introduce `iterator=True` and deprecate `as_list=False` in `list()`
+  ([`cdc6605`](https://github.com/python-gitlab/python-gitlab/commit/cdc6605767316ea59e1e1b849683be7b3b99e0ae))
+
+`as_list=False` is confusing as it doesn't explain what is being returned. Replace it with
+  `iterator=True` which more clearly explains to the user that an iterator/generator will be
+  returned.
+
+This maintains backward compatibility with `as_list` but does issue a DeprecationWarning if
+  `as_list` is set.
+
+- **docker**: Provide a Debian-based slim image
+  ([`384031c`](https://github.com/python-gitlab/python-gitlab/commit/384031c530e813f55da52f2b2c5635ea935f9d91))
+
+- **downloads**: Allow streaming downloads access to response iterator
+  ([#1956](https://github.com/python-gitlab/python-gitlab/pull/1956),
+  [`b644721`](https://github.com/python-gitlab/python-gitlab/commit/b6447211754e126f64e12fc735ad74fe557b7fb4))
+
+* feat(downloads): allow streaming downloads access to response iterator
+
+Allow access to the underlying response iterator when downloading in streaming mode by specifying
+  `iterator=True`.
+
+Update type annotations to support this change.
+
+* docs(api-docs): add iterator example to artifact download
+
+Document the usage of the `iterator=True` option when downloading artifacts
+
+* test(packages): add tests for streaming downloads
+
+- **users**: Add approve and reject methods to User
+  ([`f57139d`](https://github.com/python-gitlab/python-gitlab/commit/f57139d8f1dafa6eb19d0d954b3634c19de6413c))
+
+As requested in #1604.
+
+Co-authored-by: John Villalovos <john@sodarock.com>
+
+- **users**: Add ban and unban methods
+  ([`0d44b11`](https://github.com/python-gitlab/python-gitlab/commit/0d44b118f85f92e7beb1a05a12bdc6e070dce367))
+
+### Refactoring
+
+- Avoid possible breaking change in iterator
+  ([#2107](https://github.com/python-gitlab/python-gitlab/pull/2107),
+  [`212ddfc`](https://github.com/python-gitlab/python-gitlab/commit/212ddfc9e9c5de50d2507cc637c01ceb31aaba41))
+
+Commit b6447211754e126f64e12fc735ad74fe557b7fb4 inadvertently introduced a possible breaking change
+  as it added a new argument `iterator` and added it in between existing (potentially positional)
+  arguments.
+
+This moves the `iterator` argument to the end of the argument list and requires it to be a
+  keyword-only argument.
+
+- Do not recommend plain gitlab.const constants
+  ([`d652133`](https://github.com/python-gitlab/python-gitlab/commit/d65213385a6f497c2595d3af3a41756919b9c9a1))
+
+- Remove no-op id argument in GetWithoutIdMixin
+  ([`0f2a602`](https://github.com/python-gitlab/python-gitlab/commit/0f2a602d3a9d6579f5fdfdf945a236ae44e93a12))
+
+- **mixins**: Extract custom type transforms into utils
+  ([`09b3b22`](https://github.com/python-gitlab/python-gitlab/commit/09b3b2225361722f2439952d2dbee6a48a9f9fd9))
+
+### Testing
+
+- Add more tests for RequiredOptional
+  ([`ce40fde`](https://github.com/python-gitlab/python-gitlab/commit/ce40fde9eeaabb4a30c5a87d9097b1d4eced1c1b))
+
+- Add tests and clean up usage for new enums
+  ([`323ab3c`](https://github.com/python-gitlab/python-gitlab/commit/323ab3c5489b0d35f268bc6c22ade782cade6ba4))
+
+- Increase client coverage
+  ([`00aec96`](https://github.com/python-gitlab/python-gitlab/commit/00aec96ed0b60720362c6642b416567ff39aef09))
+
+- Move back to using latest Python 3.11 version
+  ([`8c34781`](https://github.com/python-gitlab/python-gitlab/commit/8c347813e7aaf26a33fe5ae4ae73448beebfbc6c))
+
+- **api**: Add tests for HEAD method
+  ([`b0f02fa`](https://github.com/python-gitlab/python-gitlab/commit/b0f02facef2ea30f24dbfb3c52974f34823e9bba))
+
+- **cli**: Improve coverage for custom actions
+  ([`7327f78`](https://github.com/python-gitlab/python-gitlab/commit/7327f78073caa2fb8aaa6bf0e57b38dd7782fa57))
+
+- **gitlab**: Increase unit test coverage
+  ([`df072e1`](https://github.com/python-gitlab/python-gitlab/commit/df072e130aa145a368bbdd10be98208a25100f89))
+
+- **pylint**: Enable pylint "unused-argument" check
+  ([`23feae9`](https://github.com/python-gitlab/python-gitlab/commit/23feae9b0906d34043a784a01d31d1ff19ebc9a4))
+
+Enable the pylint "unused-argument" check and resolve issues it found.
+
+* Quite a few functions were accepting `**kwargs` but not then passing them on through to the next
+  level. Now pass `**kwargs` to next level. * Other functions had no reason to accept `**kwargs`, so
+  remove it * And a few other fixes.
+
+
+## v3.5.0 (2022-05-28)
+
+### Bug Fixes
+
+- Duplicate subparsers being added to argparse
+  ([`f553fd3`](https://github.com/python-gitlab/python-gitlab/commit/f553fd3c79579ab596230edea5899dc5189b0ac6))
+
+Python 3.11 added an additional check in the argparse libary which detected duplicate subparsers
+  being added. We had duplicate subparsers being added.
+
+Make sure we don't add duplicate subparsers.
+
+Closes: #2015
+
+- **cli**: Changed default `allow_abbrev` value to fix arguments collision problem
+  ([#2013](https://github.com/python-gitlab/python-gitlab/pull/2013),
+  [`d68cacf`](https://github.com/python-gitlab/python-gitlab/commit/d68cacfeda5599c62a593ecb9da2505c22326644))
+
+fix(cli): change default `allow_abbrev` value to fix argument collision
+
+### Chores
+
+- Add `cz` to default tox environment list and skip_missing_interpreters
+  ([`ba8c052`](https://github.com/python-gitlab/python-gitlab/commit/ba8c0522dc8a116e7a22c42e21190aa205d48253))
+
+Add the `cz` (`comittizen`) check by default.
+
+Set skip_missing_interpreters = True so that when a user runs tox and doesn't have a specific
+  version of Python it doesn't mark it as an error.
+
+- Exclude `build/` directory from mypy check
+  ([`989a12b`](https://github.com/python-gitlab/python-gitlab/commit/989a12b79ac7dff8bf0d689f36ccac9e3494af01))
+
+The `build/` directory is created by the tox environment `twine-check`. When the `build/` directory
+  exists `mypy` will have an error.
+
+- Rename the test which runs `flake8` to be `flake8`
+  ([`78b4f99`](https://github.com/python-gitlab/python-gitlab/commit/78b4f995afe99c530858b7b62d3eee620f3488f2))
+
+Previously the test was called `pep8`. The test only runs `flake8` so call it `flake8` to be more
+  precise.
+
+- Run the `pylint` check by default in tox
+  ([`55ace1d`](https://github.com/python-gitlab/python-gitlab/commit/55ace1d67e75fae9d74b4a67129ff842de7e1377))
+
+Since we require `pylint` to pass in the CI. Let's run it by default in tox.
+
+- **ci**: Fix prefix for action version
+  ([`1c02189`](https://github.com/python-gitlab/python-gitlab/commit/1c021892e94498dbb6b3fa824d6d8c697fb4db7f))
+
+- **ci**: Pin semantic-release version
+  ([`0ea61cc`](https://github.com/python-gitlab/python-gitlab/commit/0ea61ccecae334c88798f80b6451c58f2fbb77c6))
+
+- **ci**: Replace commitlint with commitizen
+  ([`b8d15fe`](https://github.com/python-gitlab/python-gitlab/commit/b8d15fed0740301617445e5628ab76b6f5b8baeb))
+
+- **deps**: Update dependency pylint to v2.13.8
+  ([`b235bb0`](https://github.com/python-gitlab/python-gitlab/commit/b235bb00f3c09be5bb092a5bb7298e7ca55f2366))
+
+- **deps**: Update dependency pylint to v2.13.9
+  ([`4224950`](https://github.com/python-gitlab/python-gitlab/commit/422495073492fd52f4f3b854955c620ada4c1daa))
+
+- **deps**: Update dependency types-requests to v2.27.23
+  ([`a6fed8b`](https://github.com/python-gitlab/python-gitlab/commit/a6fed8b4a0edbe66bf29cd7a43d51d2f5b8b3e3a))
+
+- **deps**: Update dependency types-requests to v2.27.24
+  ([`f88e3a6`](https://github.com/python-gitlab/python-gitlab/commit/f88e3a641ebb83818e11713eb575ebaa597440f0))
+
+- **deps**: Update dependency types-requests to v2.27.25
+  ([`d6ea47a`](https://github.com/python-gitlab/python-gitlab/commit/d6ea47a175c17108e5388213abd59c3e7e847b02))
+
+- **deps**: Update pre-commit hook pycqa/pylint to v2.13.8
+  ([`1835593`](https://github.com/python-gitlab/python-gitlab/commit/18355938d1b410ad5e17e0af4ef0667ddb709832))
+
+- **deps**: Update pre-commit hook pycqa/pylint to v2.13.9
+  ([`1e22790`](https://github.com/python-gitlab/python-gitlab/commit/1e2279028533c3dc15995443362e290a4d2c6ae0))
+
+- **renovate**: Set schedule to reduce noise
+  ([`882fe7a`](https://github.com/python-gitlab/python-gitlab/commit/882fe7a681ae1c5120db5be5e71b196ae555eb3e))
+
+### Documentation
+
+- Add missing Admin access const value
+  ([`3e0d4d9`](https://github.com/python-gitlab/python-gitlab/commit/3e0d4d9006e2ca6effae2b01cef3926dd0850e52))
+
+As shown here, Admin access is set to 60:
+  https://docs.gitlab.com/ee/api/protected_branches.html#protected-branches-api
+
+- Update issue example and extend API usage docs
+  ([`aad71d2`](https://github.com/python-gitlab/python-gitlab/commit/aad71d282d60dc328b364bcc951d0c9b44ab13fa))
+
+- **CONTRIBUTING.rst**: Fix link to conventional-changelog commit format documentation
+  ([`2373a4f`](https://github.com/python-gitlab/python-gitlab/commit/2373a4f13ee4e5279a424416cdf46782a5627067))
+
+- **merge_requests**: Add new possible merge request state and link to the upstream docs
+  ([`e660fa8`](https://github.com/python-gitlab/python-gitlab/commit/e660fa8386ed7783da5c076bc0fef83e6a66f9a8))
+
+The actual documentation do not mention the locked state for a merge request
+
+### Features
+
+- Display human-readable attribute in `repr()` if present
+  ([`6b47c26`](https://github.com/python-gitlab/python-gitlab/commit/6b47c26d053fe352d68eb22a1eaf4b9a3c1c93e7))
+
+- **objects**: Support get project storage endpoint
+  ([`8867ee5`](https://github.com/python-gitlab/python-gitlab/commit/8867ee59884ae81d6457ad6e561a0573017cf6b2))
+
+- **ux**: Display project.name_with_namespace on project repr
+  ([`e598762`](https://github.com/python-gitlab/python-gitlab/commit/e5987626ca1643521b16658555f088412be2a339))
+
+This change the repr from:
+
+$ gitlab.projects.get(id=some_id) <Project id:some_id>
+
+To:
+
+$ gitlab.projects.get(id=some_id) <Project id:some_id name_with_namespace:"group_name /
+  project_name">
+
+This is especially useful when working on random projects or listing of projects since users
+  generally don't remember projects ids.
+
+### Testing
+
+- **projects**: Add tests for list project methods
+  ([`fa47829`](https://github.com/python-gitlab/python-gitlab/commit/fa47829056a71e6b9b7f2ce913f2aebc36dc69e9))
+
+
+## v3.4.0 (2022-04-28)
+
+### Bug Fixes
+
+- Add 52x range to retry transient failures and tests
+  ([`c3ef1b5`](https://github.com/python-gitlab/python-gitlab/commit/c3ef1b5c1eaf1348a18d753dbf7bda3c129e3262))
+
+- Add ChunkedEncodingError to list of retryable exceptions
+  ([`7beb20f`](https://github.com/python-gitlab/python-gitlab/commit/7beb20ff7b7b85fb92fc6b647d9c1bdb7568f27c))
+
+- Also retry HTTP-based transient errors
+  ([`3b49e4d`](https://github.com/python-gitlab/python-gitlab/commit/3b49e4d61e6f360f1c787aa048edf584aec55278))
+
+- Avoid passing redundant arguments to API
+  ([`3431887`](https://github.com/python-gitlab/python-gitlab/commit/34318871347b9c563d01a13796431c83b3b1d58c))
+
+- **cli**: Add missing filters for project commit list
+  ([`149d244`](https://github.com/python-gitlab/python-gitlab/commit/149d2446fcc79b31d3acde6e6d51adaf37cbb5d3))
+
+### Chores
+
+- **client**: Remove duplicate code
+  ([`5cbbf26`](https://github.com/python-gitlab/python-gitlab/commit/5cbbf26e6f6f3ce4e59cba735050e3b7f9328388))
+
+- **deps**: Update black to v22.3.0
+  ([`8d48224`](https://github.com/python-gitlab/python-gitlab/commit/8d48224c89cf280e510fb5f691e8df3292577f64))
+
+- **deps**: Update codecov/codecov-action action to v3
+  ([`292e91b`](https://github.com/python-gitlab/python-gitlab/commit/292e91b3cbc468c4a40ed7865c3c98180c1fe864))
+
+- **deps**: Update dependency mypy to v0.950
+  ([`241e626`](https://github.com/python-gitlab/python-gitlab/commit/241e626c8e88bc1b6b3b2fc37e38ed29b6912b4e))
+
+- **deps**: Update dependency pylint to v2.13.3
+  ([`0ae3d20`](https://github.com/python-gitlab/python-gitlab/commit/0ae3d200563819439be67217a7fc0e1552f07c90))
+
+- **deps**: Update dependency pylint to v2.13.4
+  ([`a9a9392`](https://github.com/python-gitlab/python-gitlab/commit/a9a93921b795eee0db16e453733f7c582fa13bc9))
+
+- **deps**: Update dependency pylint to v2.13.5
+  ([`5709675`](https://github.com/python-gitlab/python-gitlab/commit/570967541ecd46bfb83461b9d2c95bb0830a84fa))
+
+- **deps**: Update dependency pylint to v2.13.7
+  ([`5fb2234`](https://github.com/python-gitlab/python-gitlab/commit/5fb2234dddf73851b5de7af5d61b92de022a892a))
+
+- **deps**: Update dependency pytest to v7.1.2
+  ([`fd3fa23`](https://github.com/python-gitlab/python-gitlab/commit/fd3fa23bd4f7e0d66b541780f94e15635851e0db))
+
+- **deps**: Update dependency types-requests to v2.27.16
+  ([`ad799fc`](https://github.com/python-gitlab/python-gitlab/commit/ad799fca51a6b2679e2bcca8243a139e0bd0acf5))
+
+- **deps**: Update dependency types-requests to v2.27.21
+  ([`0fb0955`](https://github.com/python-gitlab/python-gitlab/commit/0fb0955b93ee1c464b3a5021bc22248103742f1d))
+
+- **deps**: Update dependency types-requests to v2.27.22
+  ([`22263e2`](https://github.com/python-gitlab/python-gitlab/commit/22263e24f964e56ec76d8cb5243f1cad1d139574))
+
+- **deps**: Update dependency types-setuptools to v57.4.12
+  ([`6551353`](https://github.com/python-gitlab/python-gitlab/commit/65513538ce60efdde80e5e0667b15739e6d90ac1))
+
+- **deps**: Update pre-commit hook pycqa/pylint to v2.13.3
+  ([`8f0a3af`](https://github.com/python-gitlab/python-gitlab/commit/8f0a3af46a1f49e6ddba31ee964bbe08c54865e0))
+
+- **deps**: Update pre-commit hook pycqa/pylint to v2.13.4
+  ([`9d0b252`](https://github.com/python-gitlab/python-gitlab/commit/9d0b25239773f98becea3b5b512d50f89631afb5))
+
+- **deps**: Update pre-commit hook pycqa/pylint to v2.13.5
+  ([`17d5c6c`](https://github.com/python-gitlab/python-gitlab/commit/17d5c6c3ba26f8b791ec4571726c533f5bbbde7d))
+
+- **deps**: Update pre-commit hook pycqa/pylint to v2.13.7
+  ([`1396221`](https://github.com/python-gitlab/python-gitlab/commit/1396221a96ea2f447b0697f589a50a9c22504c00))
+
+- **deps**: Update typing dependencies
+  ([`c12466a`](https://github.com/python-gitlab/python-gitlab/commit/c12466a0e7ceebd3fb9f161a472bbbb38e9bd808))
+
+- **deps**: Update typing dependencies
+  ([`d27cc6a`](https://github.com/python-gitlab/python-gitlab/commit/d27cc6a1219143f78aad7e063672c7442e15672e))
+
+- **deps**: Upgrade gitlab-ce to 14.9.2-ce.0
+  ([`d508b18`](https://github.com/python-gitlab/python-gitlab/commit/d508b1809ff3962993a2279b41b7d20e42d6e329))
+
+### Documentation
+
+- **api-docs**: Docs fix for application scopes
+  ([`e1ad93d`](https://github.com/python-gitlab/python-gitlab/commit/e1ad93df90e80643866611fe52bd5c59428e7a88))
+
+### Features
+
+- Emit a warning when using a `list()` method returns max
+  ([`1339d64`](https://github.com/python-gitlab/python-gitlab/commit/1339d645ce58a2e1198b898b9549ba5917b1ff12))
+
+A common cause of issues filed and questions raised is that a user will call a `list()` method and
+  only get 20 items. As this is the default maximum of items that will be returned from a `list()`
+  method.
+
+To help with this we now emit a warning when the result from a `list()` method is greater-than or
+  equal to 20 (or the specified `per_page` value) and the user is not using either `all=True`,
+  `all=False`, `as_list=False`, or `page=X`.
+
+- **api**: Re-add topic delete endpoint
+  ([`d1d96bd`](https://github.com/python-gitlab/python-gitlab/commit/d1d96bda5f1c6991c8ea61dca8f261e5b74b5ab6))
+
+This reverts commit e3035a799a484f8d6c460f57e57d4b59217cd6de.
+
+- **objects**: Support getting project/group deploy tokens by id
+  ([`fcd37fe`](https://github.com/python-gitlab/python-gitlab/commit/fcd37feff132bd5b225cde9d5f9c88e62b3f1fd6))
+
+- **user**: Support getting user SSH key by id
+  ([`6f93c05`](https://github.com/python-gitlab/python-gitlab/commit/6f93c0520f738950a7c67dbeca8d1ac8257e2661))
+
+
+## v3.3.0 (2022-03-28)
+
+### Bug Fixes
+
+- Support RateLimit-Reset header
+  ([`4060146`](https://github.com/python-gitlab/python-gitlab/commit/40601463c78a6f5d45081700164899b2559b7e55))
+
+Some endpoints are not returning the `Retry-After` header when rate-limiting occurrs. In those cases
+  use the `RateLimit-Reset` [1] header, if available.
+
+Closes: #1889
+
+[1]
+  https://docs.gitlab.com/ee/user/admin_area/settings/user_and_ip_rate_limits.html#response-headers
+
+### Chores
+
+- **deps**: Update actions/checkout action to v3
+  ([`7333cbb`](https://github.com/python-gitlab/python-gitlab/commit/7333cbb65385145a14144119772a1854b41ea9d8))
+
+- **deps**: Update actions/setup-python action to v3
+  ([`7f845f7`](https://github.com/python-gitlab/python-gitlab/commit/7f845f7eade3c0cdceec6bfe7b3d087a8586edc5))
+
+- **deps**: Update actions/stale action to v5
+  ([`d841185`](https://github.com/python-gitlab/python-gitlab/commit/d8411853e224a198d0ead94242acac3aadef5adc))
+
+- **deps**: Update actions/upload-artifact action to v3
+  ([`18a0eae`](https://github.com/python-gitlab/python-gitlab/commit/18a0eae11c480d6bd5cf612a94e56cb9562e552a))
+
+- **deps**: Update black to v22
+  ([`3f84f1b`](https://github.com/python-gitlab/python-gitlab/commit/3f84f1bb805691b645fac2d1a41901abefccb17e))
+
+- **deps**: Update dependency mypy to v0.931
+  ([`33646c1`](https://github.com/python-gitlab/python-gitlab/commit/33646c1c4540434bed759d903c9b83af4e7d1a82))
+
+- **deps**: Update dependency mypy to v0.940
+  ([`dd11084`](https://github.com/python-gitlab/python-gitlab/commit/dd11084dd281e270a480b338aba88b27b991e58e))
+
+- **deps**: Update dependency mypy to v0.941
+  ([`3a9d4f1`](https://github.com/python-gitlab/python-gitlab/commit/3a9d4f1dc2069e29d559967e1f5498ccadf62591))
+
+- **deps**: Update dependency mypy to v0.942
+  ([`8ba0f8c`](https://github.com/python-gitlab/python-gitlab/commit/8ba0f8c6b42fa90bd1d7dd7015a546e8488c3f73))
+
+- **deps**: Update dependency pylint to v2.13.0
+  ([`5fa403b`](https://github.com/python-gitlab/python-gitlab/commit/5fa403bc461ed8a4d183dcd8f696c2a00b64a33d))
+
+- **deps**: Update dependency pylint to v2.13.1
+  ([`eefd724`](https://github.com/python-gitlab/python-gitlab/commit/eefd724545de7c96df2f913086a7f18020a5470f))
+
+- **deps**: Update dependency pylint to v2.13.2
+  ([`10f15a6`](https://github.com/python-gitlab/python-gitlab/commit/10f15a625187f2833be72d9bf527e75be001d171))
+
+- **deps**: Update dependency pytest to v7
+  ([`ae8d70d`](https://github.com/python-gitlab/python-gitlab/commit/ae8d70de2ad3ceb450a33b33e189bb0a3f0ff563))
+
+- **deps**: Update dependency pytest to v7.1.0
+  ([`27c7e33`](https://github.com/python-gitlab/python-gitlab/commit/27c7e3350839aaf5c06a15c1482fc2077f1d477a))
+
+- **deps**: Update dependency pytest to v7.1.1
+  ([`e31f2ef`](https://github.com/python-gitlab/python-gitlab/commit/e31f2efe97995f48c848f32e14068430a5034261))
+
+- **deps**: Update dependency pytest-console-scripts to v1.3
+  ([`9c202dd`](https://github.com/python-gitlab/python-gitlab/commit/9c202dd5a2895289c1f39068f0ea09812f28251f))
+
+- **deps**: Update dependency pytest-console-scripts to v1.3.1
+  ([`da392e3`](https://github.com/python-gitlab/python-gitlab/commit/da392e33e58d157169e5aa3f1fe725457e32151c))
+
+- **deps**: Update dependency requests to v2.27.1
+  ([`95dad55`](https://github.com/python-gitlab/python-gitlab/commit/95dad55b0cb02fd30172b5b5b9b05a25473d1f03))
+
+- **deps**: Update dependency sphinx to v4.4.0
+  ([`425d161`](https://github.com/python-gitlab/python-gitlab/commit/425d1610ca19be775d9fdd857e61d8b4a4ae4db3))
+
+- **deps**: Update dependency sphinx to v4.5.0
+  ([`36ab769`](https://github.com/python-gitlab/python-gitlab/commit/36ab7695f584783a4b3272edd928de3b16843a36))
+
+- **deps**: Update dependency types-requests to v2.27.12
+  ([`8cd668e`](https://github.com/python-gitlab/python-gitlab/commit/8cd668efed7bbbca370634e8c8cb10e3c7a13141))
+
+- **deps**: Update dependency types-requests to v2.27.14
+  ([`be6b54c`](https://github.com/python-gitlab/python-gitlab/commit/be6b54c6028036078ef09013f6c51c258173f3ca))
+
+- **deps**: Update dependency types-requests to v2.27.15
+  ([`2e8ecf5`](https://github.com/python-gitlab/python-gitlab/commit/2e8ecf569670afc943e8a204f3b2aefe8aa10d8b))
+
+- **deps**: Update dependency types-setuptools to v57.4.10
+  ([`b37fc41`](https://github.com/python-gitlab/python-gitlab/commit/b37fc4153a00265725ca655bc4482714d6b02809))
+
+- **deps**: Update pre-commit hook alessandrojcm/commitlint-pre-commit-hook to v8
+  ([`5440780`](https://github.com/python-gitlab/python-gitlab/commit/544078068bc9d7a837e75435e468e4749f7375ac))
+
+- **deps**: Update pre-commit hook pycqa/pylint to v2.13.0
+  ([`9fe60f7`](https://github.com/python-gitlab/python-gitlab/commit/9fe60f7b8fa661a8bba61c04fcb5b54359ac6778))
+
+- **deps**: Update pre-commit hook pycqa/pylint to v2.13.1
+  ([`1d0c6d4`](https://github.com/python-gitlab/python-gitlab/commit/1d0c6d423ce9f6c98511578acbb0f08dc4b93562))
+
+- **deps**: Update pre-commit hook pycqa/pylint to v2.13.2
+  ([`14d367d`](https://github.com/python-gitlab/python-gitlab/commit/14d367d60ab8f1e724c69cad0f39c71338346948))
+
+- **deps**: Update typing dependencies
+  ([`21e7c37`](https://github.com/python-gitlab/python-gitlab/commit/21e7c3767aa90de86046a430c7402f0934950e62))
+
+- **deps**: Update typing dependencies
+  ([`37a7c40`](https://github.com/python-gitlab/python-gitlab/commit/37a7c405c975359e9c1f77417e67063326c82a42))
+
+### Code Style
+
+- Reformat for black v22
+  ([`93d4403`](https://github.com/python-gitlab/python-gitlab/commit/93d4403f0e46ed354cbcb133821d00642429532f))
+
+### Documentation
+
+- Add pipeline test report summary support
+  ([`d78afb3`](https://github.com/python-gitlab/python-gitlab/commit/d78afb36e26f41d727dee7b0952d53166e0df850))
+
+- Fix typo and incorrect style
+  ([`2828b10`](https://github.com/python-gitlab/python-gitlab/commit/2828b10505611194bebda59a0e9eb41faf24b77b))
+
+- **chore**: Include docs .js files in sdist
+  ([`3010b40`](https://github.com/python-gitlab/python-gitlab/commit/3010b407bc9baabc6cef071507e8fa47c0f1624d))
+
+### Features
+
+- **object**: Add pipeline test report summary support
+  ([`a97e0cf`](https://github.com/python-gitlab/python-gitlab/commit/a97e0cf81b5394b3a2b73d927b4efe675bc85208))
+
+
+## v3.2.0 (2022-02-28)
+
+### Bug Fixes
+
+- Remove custom `delete` method for labels
+  ([`0841a2a`](https://github.com/python-gitlab/python-gitlab/commit/0841a2a686c6808e2f3f90960e529b26c26b268f))
+
+The usage of deleting was incorrect according to the current API. Remove custom `delete()` method as
+  not needed.
+
+Add tests to show it works with labels needing to be encoded.
+
+Also enable the test_group_labels() test function. Previously it was disabled.
+
+Add ability to do a `get()` for group labels.
+
+Closes: #1867
+
+- **services**: Use slug for id_attr instead of custom methods
+  ([`e30f39d`](https://github.com/python-gitlab/python-gitlab/commit/e30f39dff5726266222b0f56c94f4ccfe38ba527))
+
+### Chores
+
+- Correct type-hints for per_page attrbute
+  ([`e825653`](https://github.com/python-gitlab/python-gitlab/commit/e82565315330883823bd5191069253a941cb2683))
+
+There are occasions where a GitLab `list()` call does not return the `x-per-page` header. For
+  example the listing of custom attributes.
+
+Update the type-hints to reflect that.
+
+- Create a custom `warnings.warn` wrapper
+  ([`6ca9aa2`](https://github.com/python-gitlab/python-gitlab/commit/6ca9aa2960623489aaf60324b4709848598aec91))
+
+Create a custom `warnings.warn` wrapper that will walk the stack trace to find the first frame
+  outside of the `gitlab/` path to print the warning against. This will make it easier for users to
+  find where in their code the error is generated from
+
+- Create new ArrayAttribute class
+  ([`a57334f`](https://github.com/python-gitlab/python-gitlab/commit/a57334f1930752c70ea15847a39324fa94042460))
+
+Create a new ArrayAttribute class. This is to indicate types which are sent to the GitLab server as
+  arrays https://docs.gitlab.com/ee/api/#array
+
+At this stage it is identical to the CommaSeparatedListAttribute class but will be used later to
+  support the array types sent to GitLab.
+
+This is the second step in a series of steps of our goal to add full support for the GitLab API data
+  types[1]: * array * hash * array of hashes
+
+Step one was: commit 5127b1594c00c7364e9af15e42d2e2f2d909449b
+
+[1] https://docs.gitlab.com/ee/api/#encoding-api-parameters-of-array-and-hash-types
+
+Related: #1698
+
+- Require kwargs for `utils.copy_dict()`
+  ([`7cf35b2`](https://github.com/python-gitlab/python-gitlab/commit/7cf35b2c0e44732ca02b74b45525cc7c789457fb))
+
+The non-keyword arguments were a tiny bit confusing as the destination was first and the source was
+  second.
+
+Change the order and require key-word only arguments to ensure we don't silently break anyone.
+
+- **ci**: Do not run release workflow in forks
+  ([`2b6edb9`](https://github.com/python-gitlab/python-gitlab/commit/2b6edb9a0c62976ff88a95a953e9d3f2c7f6f144))
+
+### Code Style
+
+- **objects**: Add spacing to docstrings
+  ([`700d25d`](https://github.com/python-gitlab/python-gitlab/commit/700d25d9bd812a64f5f1287bf50e8ddc237ec553))
+
+### Documentation
+
+- Add delete methods for runners and project artifacts
+  ([`5e711fd`](https://github.com/python-gitlab/python-gitlab/commit/5e711fdb747fb3dcde1f5879c64dfd37bf25f3c0))
+
+- Add retry_transient infos
+  ([`bb1f054`](https://github.com/python-gitlab/python-gitlab/commit/bb1f05402887c78f9898fbd5bd66e149eff134d9))
+
+Co-authored-by: Nejc Habjan <hab.nejc@gmail.com>
+
+- Add transient errors retry info
+  ([`b7a1266`](https://github.com/python-gitlab/python-gitlab/commit/b7a126661175a3b9b73dbb4cb88709868d6d871c))
+
+- Enable gitter chat directly in docs
+  ([`bd1ecdd`](https://github.com/python-gitlab/python-gitlab/commit/bd1ecdd5ad654b01b34e7a7a96821cc280b3ca67))
+
+- Revert "chore: add temporary banner for v3"
+  ([#1864](https://github.com/python-gitlab/python-gitlab/pull/1864),
+  [`7a13b9b`](https://github.com/python-gitlab/python-gitlab/commit/7a13b9bfa4aead6c731f9a92e0946dba7577c61b))
+
+This reverts commit a349793307e3a975bb51f864b48e5e9825f70182.
+
+Co-authored-by: Wadim Klincov <wadim.klincov@siemens.com>
+
+- **artifacts**: Deprecate artifacts() and artifact() methods
+  ([`64d01ef`](https://github.com/python-gitlab/python-gitlab/commit/64d01ef23b1269b705350106d8ddc2962a780dce))
+
+### Features
+
+- **artifacts**: Add support for project artifacts delete API
+  ([`c01c034`](https://github.com/python-gitlab/python-gitlab/commit/c01c034169789e1d20fd27a0f39f4c3c3628a2bb))
+
+- **merge_request_approvals**: Add support for deleting MR approval rules
+  ([`85a734f`](https://github.com/python-gitlab/python-gitlab/commit/85a734fec3111a4a5c4f0ddd7cb36eead96215e9))
+
+- **mixins**: Allow deleting resources without IDs
+  ([`0717517`](https://github.com/python-gitlab/python-gitlab/commit/0717517212b616cfd52cfd38dd5c587ff8f9c47c))
+
+- **objects**: Add a complete artifacts manager
+  ([`c8c2fa7`](https://github.com/python-gitlab/python-gitlab/commit/c8c2fa763558c4d9906e68031a6602e007fec930))
+
+### Testing
+
+- **functional**: Fix GitLab configuration to support pagination
+  ([`5b7d00d`](https://github.com/python-gitlab/python-gitlab/commit/5b7d00df466c0fe894bafeb720bf94ffc8cd38fd))
+
+When pagination occurs python-gitlab uses the URL provided by the GitLab server to use for the next
+  request.
+
+We had previously set the GitLab server configuraiton to say its URL was `http://gitlab.test` which
+  is not in DNS. Set the hostname in the URL to `http://127.0.0.1:8080` which is the correct URL for
+  the GitLab server to be accessed while doing functional tests.
+
+Closes: #1877
+
+- **objects**: Add tests for project artifacts
+  ([`8ce0336`](https://github.com/python-gitlab/python-gitlab/commit/8ce0336325b339fa82fe4674a528f4bb59963df7))
+
+- **runners**: Add test for deleting runners by auth token
+  ([`14b88a1`](https://github.com/python-gitlab/python-gitlab/commit/14b88a13914de6ee54dd2a3bd0d5960a50578064))
+
+- **services**: Add functional tests for services
+  ([`2fea2e6`](https://github.com/python-gitlab/python-gitlab/commit/2fea2e64c554fd92d14db77cc5b1e2976b27b609))
+
+- **unit**: Clean up MR approvals fixtures
+  ([`0eb4f7f`](https://github.com/python-gitlab/python-gitlab/commit/0eb4f7f06c7cfe79c5d6695be82ac9ca41c8057e))
+
+
+## v3.1.1 (2022-01-28)
+
+### Bug Fixes
+
+- **cli**: Allow custom methods in managers
+  ([`8dfed0c`](https://github.com/python-gitlab/python-gitlab/commit/8dfed0c362af2c5e936011fd0b488b8b05e8a8a0))
+
+- **cli**: Make 'per_page' and 'page' type explicit
+  ([`d493a5e`](https://github.com/python-gitlab/python-gitlab/commit/d493a5e8685018daa69c92e5942cbe763e5dac62))
+
+- **cli**: Make 'timeout' type explicit
+  ([`bbb7df5`](https://github.com/python-gitlab/python-gitlab/commit/bbb7df526f4375c438be97d8cfa0d9ea9d604e7d))
+
+- **objects**: Make resource access tokens and repos available in CLI
+  ([`e0a3a41`](https://github.com/python-gitlab/python-gitlab/commit/e0a3a41ce60503a25fa5c26cf125364db481b207))
+
+### Chores
+
+- Always use context manager for file IO
+  ([`e8031f4`](https://github.com/python-gitlab/python-gitlab/commit/e8031f42b6804415c4afee4302ab55462d5848ac))
+
+- Consistently use open() encoding and file descriptor
+  ([`dc32d54`](https://github.com/python-gitlab/python-gitlab/commit/dc32d54c49ccc58c01cd436346a3fbfd4a538778))
+
+- Create return type-hints for `get_id()` & `encoded_id`
+  ([`0c3a1d1`](https://github.com/python-gitlab/python-gitlab/commit/0c3a1d163895f660340a6c2b2f196ad996542518))
+
+Create return type-hints for `RESTObject.get_id()` and `RESTObject.encoded_id`. Previously was
+  saying they return Any. Be more precise in saying they can return either: None, str, or int.
+
+- Don't explicitly pass args to super()
+  ([`618267c`](https://github.com/python-gitlab/python-gitlab/commit/618267ced7aaff46d8e03057fa0cab48727e5dc0))
+
+- Remove old-style classes
+  ([`ae2a015`](https://github.com/python-gitlab/python-gitlab/commit/ae2a015db1017d3bf9b5f1c5893727da9b0c937f))
+
+- Remove redundant list comprehension
+  ([`271cfd3`](https://github.com/python-gitlab/python-gitlab/commit/271cfd3651e4e9cda974d5c3f411cecb6dca6c3c))
+
+- Rename `gitlab/__version__.py` -> `gitlab/_version.py`
+  ([`b981ce7`](https://github.com/python-gitlab/python-gitlab/commit/b981ce7fed88c5d86a3fffc4ee3f99be0b958c1d))
+
+It is confusing to have a `gitlab/__version__.py` because we also create a variable
+  `gitlab.__version__` which can conflict with `gitlab/__version__.py`.
+
+For example in `gitlab/const.py` we have to know that `gitlab.__version__` is a module and not the
+  variable due to the ordering of imports. But in most other usage `gitlab.__version__` is a version
+  string.
+
+To reduce confusion make the name of the version file `gitlab/_version.py`.
+
+- Rename `types.ListAttribute` to `types.CommaSeparatedListAttribute`
+  ([`5127b15`](https://github.com/python-gitlab/python-gitlab/commit/5127b1594c00c7364e9af15e42d2e2f2d909449b))
+
+This name more accurately describes what the type is. Also this is the first step in a series of
+  steps of our goal to add full support for the GitLab API data types[1]: * array * hash * array of
+  hashes
+
+[1] https://docs.gitlab.com/ee/api/#encoding-api-parameters-of-array-and-hash-types
+
+- Use dataclass for RequiredOptional
+  ([`30117a3`](https://github.com/python-gitlab/python-gitlab/commit/30117a3b6a8ee24362de798b2fa596a343b8774f))
+
+- **tests**: Use method `projects.transfer()`
+  ([`e5af2a7`](https://github.com/python-gitlab/python-gitlab/commit/e5af2a720cb5f97e5a7a5f639095fad76a48f218))
+
+When doing the functional tests use the new function `projects.transfer` instead of the deprecated
+  function `projects.transfer_project()`
+
+### Code Style
+
+- Use f-strings where applicable
+  ([`cfed622`](https://github.com/python-gitlab/python-gitlab/commit/cfed62242e93490b8548c79f4ad16bd87de18e3e))
+
+- Use literals to declare data structures
+  ([`019a40f`](https://github.com/python-gitlab/python-gitlab/commit/019a40f840da30c74c1e74522a7707915061c756))
+
+### Documentation
+
+- Enhance release docs for CI_JOB_TOKEN usage
+  ([`5d973de`](https://github.com/python-gitlab/python-gitlab/commit/5d973de8a5edd08f38031cf9be2636b0e12f008d))
+
+- **changelog**: Add missing changelog items
+  ([`01755fb`](https://github.com/python-gitlab/python-gitlab/commit/01755fb56a5330aa6fa4525086e49990e57ce50b))
+
+### Testing
+
+- Add a meta test to make sure that v4/objects/ files are imported
+  ([`9c8c804`](https://github.com/python-gitlab/python-gitlab/commit/9c8c8043e6d1d9fadb9f10d47d7f4799ab904e9c))
+
+Add a test to make sure that all of the `gitlab/v4/objects/` files are imported in
+  `gitlab/v4/objects/__init__.py`
+
+- Convert usage of `match_querystring` to `match`
+  ([`d16e41b`](https://github.com/python-gitlab/python-gitlab/commit/d16e41bda2c355077cbdc419fe2e1d994fdea403))
+
+In the `responses` library the usage of `match_querystring` is deprecated. Convert to using `match`
+
+- Remove usage of httpmock library
+  ([`5254f19`](https://github.com/python-gitlab/python-gitlab/commit/5254f193dc29d8854952aada19a72e5b4fc7ced0))
+
+Convert all usage of the `httpmock` library to using the `responses` library.
+
+- Use 'responses' in test_mixins_methods.py
+  ([`208da04`](https://github.com/python-gitlab/python-gitlab/commit/208da04a01a4b5de8dc34e62c87db4cfa4c0d9b6))
+
+Convert from httmock to responses in test_mixins_methods.py
+
+This leaves only one file left to convert
+
+
+## v3.1.0 (2022-01-14)
+
+### Bug Fixes
+
+- Broken URL for FAQ about attribute-error-list
+  ([`1863f30`](https://github.com/python-gitlab/python-gitlab/commit/1863f30ea1f6fb7644b3128debdbb6b7bb218836))
+
+The URL was missing a 'v' before the version number and thus the page did not exist.
+
+Previously the URL for python-gitlab 3.0.0 was:
+  https://python-gitlab.readthedocs.io/en/3.0.0/faq.html#attribute-error-list
+
+Which does not exist.
+
+Change it to: https://python-gitlab.readthedocs.io/en/v3.0.0/faq.html#attribute-error-list add the
+  'v' --------------------------^
+
+- Change to `http_list` for some ProjectCommit methods
+  ([`497e860`](https://github.com/python-gitlab/python-gitlab/commit/497e860d834d0757d1c6532e107416c6863f52f2))
+
+Fix the type-hints and use `http_list()` for the ProjectCommits methods: - diff() - merge_requests()
+  - refs()
+
+This will enable using the pagination support we have for lists.
+
+Closes: #1805
+
+Closes: #1231
+
+- Remove custom URL encoding
+  ([`3d49e5e`](https://github.com/python-gitlab/python-gitlab/commit/3d49e5e6a2bf1c9a883497acb73d7ce7115b804d))
+
+We were using `str.replace()` calls to take care of URL encoding issues.
+
+Switch them to use our `utils._url_encode()` function which itself uses `urllib.parse.quote()`
+
+Closes: #1356
+
+- Remove default arguments for mergerequests.merge()
+  ([`8e589c4`](https://github.com/python-gitlab/python-gitlab/commit/8e589c43fa2298dc24b97423ffcc0ce18d911e3b))
+
+The arguments `should_remove_source_branch` and `merge_when_pipeline_succeeds` are optional
+  arguments. We should not be setting any default value for them.
+
+https://docs.gitlab.com/ee/api/merge_requests.html#accept-mr
+
+Closes: #1750
+
+- Use url-encoded ID in all paths
+  ([`12435d7`](https://github.com/python-gitlab/python-gitlab/commit/12435d74364ca881373d690eab89d2e2baa62a49))
+
+Make sure all usage of the ID in the URL path is encoded. Normally it isn't an issue as most IDs are
+  integers or strings which don't contain a slash ('/'). But when the ID is a string with a slash
+  character it will break things.
+
+Add a test case that shows this fixes wikis issue with subpages which use the slash character.
+
+Closes: #1079
+
+- **api**: Services: add missing `lazy` parameter
+  ([`888f332`](https://github.com/python-gitlab/python-gitlab/commit/888f3328d3b1c82a291efbdd9eb01f11dff0c764))
+
+Commit 8da0b758c589f608a6ae4eeb74b3f306609ba36d added the `lazy` parameter to the services `get()`
+  method but missed then using the `lazy` parameter when it called `super(...).get(...)`
+
+Closes: #1828
+
+- **cli**: Add missing list filters for environments
+  ([`6f64d40`](https://github.com/python-gitlab/python-gitlab/commit/6f64d4098ed4a890838c6cf43d7a679e6be4ac6c))
+
+- **cli**: Url-encode path components of the URL
+  ([`ac1c619`](https://github.com/python-gitlab/python-gitlab/commit/ac1c619cae6481833f5df91862624bf0380fef67))
+
+In the CLI we need to make sure the components put into the path portion of the URL are url-encoded.
+  Otherwise they will be interpreted as part of the path. For example can specify the project ID as
+  a path, but in the URL it must be url-encoded or it doesn't work.
+
+Also stop adding the components of the path as query parameters in the URL.
+
+Closes: #783
+
+Closes: #1498
+
+- **members**: Use new *All objects for *AllManager managers
+  ([`755e0a3`](https://github.com/python-gitlab/python-gitlab/commit/755e0a32e8ca96a3a3980eb7d7346a1a899ad58b))
+
+Change it so that:
+
+GroupMemberAllManager uses GroupMemberAll object ProjectMemberAllManager uses ProjectMemberAll
+  object
+
+Create GroupMemberAll and ProjectMemberAll objects that do not support any Mixin type methods.
+  Previously we were using GroupMember and ProjectMember which support the `save()` and `delete()`
+  methods but those methods will not work with objects retrieved using the `/members/all/` API
+  calls.
+
+`list()` API calls: [1] GET /groups/:id/members/all GET /projects/:id/members/all
+
+`get()` API calls: [2] GET /groups/:id/members/all/:user_id GET /projects/:id/members/all/:user_id
+
+Closes: #1825
+
+Closes: #848
+
+[1]
+  https://docs.gitlab.com/ee/api/members.html#list-all-members-of-a-group-or-project-including-inherited-and-invited-members
+  [2]
+  https://docs.gitlab.com/ee/api/members.html#get-a-member-of-a-group-or-project-including-inherited-and-invited-members
+
+### Chores
+
+- Add `pprint()` and `pformat()` methods to RESTObject
+  ([`d69ba04`](https://github.com/python-gitlab/python-gitlab/commit/d69ba0479a4537bbc7a53f342661c1984382f939))
+
+This is useful in debugging and testing. As can easily print out the values from an instance in a
+  more human-readable form.
+
+- Add a stale workflow
+  ([`2c036a9`](https://github.com/python-gitlab/python-gitlab/commit/2c036a992c9d7fdf6ccf0d3132d9b215c6d197f5))
+
+Use the stale action to close issues and pull-requests with no activity.
+
+Issues: It will mark them as stale after 60 days and then close
+
+them once they have been stale for 15 days.
+
+Pull-Requests: It will mark pull-requests as stale after 90 days and then close
+
+https://github.com/actions/stale
+
+Closes: #1649
+
+- Add EncodedId string class to use to hold URL-encoded paths
+  ([`a2e7c38`](https://github.com/python-gitlab/python-gitlab/commit/a2e7c383e10509b6eb0fa8760727036feb0807c8))
+
+Add EncodedId string class. This class returns a URL-encoded string but ensures it will only
+  URL-encode it once even if recursively called.
+
+Also added some functional tests of 'lazy' objects to make sure they work.
+
+- Add functional test of mergerequest.get()
+  ([`a92b55b`](https://github.com/python-gitlab/python-gitlab/commit/a92b55b81eb3586e4144f9332796c94747bf9cfe))
+
+Add a functional test of test mergerequest.get() and mergerequest.get(..., lazy=True)
+
+Closes: #1425
+
+- Add logging to `tests/functional/conftest.py`
+  ([`a1ac9ae`](https://github.com/python-gitlab/python-gitlab/commit/a1ac9ae63828ca2012289817410d420da066d8df))
+
+I have found trying to debug issues in the functional tests can be difficult. Especially when trying
+  to figure out failures in the CI running on Github.
+
+Add logging to `tests/functional/conftest.py` to have a better understanding of what is happening
+  during a test run which is useful when trying to troubleshoot issues in the CI.
+
+- Add temporary banner for v3
+  ([`a349793`](https://github.com/python-gitlab/python-gitlab/commit/a349793307e3a975bb51f864b48e5e9825f70182))
+
+- Fix functional test failure if config present
+  ([`c9ed3dd`](https://github.com/python-gitlab/python-gitlab/commit/c9ed3ddc1253c828dc877dcd55000d818c297ee7))
+
+Previously c8256a5933d745f70c7eea0a7d6230b51bac0fbc was done to fix this but it missed two other
+  failures.
+
+- Fix missing comma
+  ([`7c59fac`](https://github.com/python-gitlab/python-gitlab/commit/7c59fac12fe69a1080cc227512e620ac5ae40b13))
+
+There was a missing comma which meant the strings were concatenated instead of being two separate
+  strings.
+
+- Ignore intermediate coverage artifacts
+  ([`110ae91`](https://github.com/python-gitlab/python-gitlab/commit/110ae9100b407356925ac2d2ffc65e0f0d50bd70))
+
+- Replace usage of utils._url_encode() with utils.EncodedId()
+  ([`b07eece`](https://github.com/python-gitlab/python-gitlab/commit/b07eece0a35dbc48076c9ec79f65f1e3fa17a872))
+
+utils.EncodedId() has basically the same functionalityy of using utils._url_encode(). So remove
+  utils._url_encode() as we don't need it.
+
+- **dist**: Add docs *.md files to sdist
+  ([`d9457d8`](https://github.com/python-gitlab/python-gitlab/commit/d9457d860ae7293ca218ab25e9501b0f796caa57))
+
+build_sphinx to fail due to setup.cfg warning-is-error
+
+- **docs**: Use admonitions consistently
+  ([`55c67d1`](https://github.com/python-gitlab/python-gitlab/commit/55c67d1fdb81dcfdf8f398b3184fc59256af513d))
+
+- **groups**: Use encoded_id for group path
+  ([`868f243`](https://github.com/python-gitlab/python-gitlab/commit/868f2432cae80578d99db91b941332302dd31c89))
+
+- **objects**: Use `self.encoded_id` where applicable
+  ([`75758bf`](https://github.com/python-gitlab/python-gitlab/commit/75758bf26bca286ec57d5cef2808560c395ff7ec))
+
+Updated a few remaining usages of `self.id` to use `self.encoded_id` as for the most part we
+  shouldn't be using `self.id`
+
+There are now only a few (4 lines of code) remaining uses of `self.id`, most of which seem that they
+  should stay that way.
+
+- **objects**: Use `self.encoded_id` where could be a string
+  ([`c3c3a91`](https://github.com/python-gitlab/python-gitlab/commit/c3c3a914fa2787ae6a1368fe6550585ee252c901))
+
+Updated a few remaining usages of `self.id` to use `self.encoded_id` where it could be a string
+  value.
+
+- **projects**: Fix typing for transfer method
+  ([`0788fe6`](https://github.com/python-gitlab/python-gitlab/commit/0788fe677128d8c25db1cc107fef860a5a3c2a42))
+
+Co-authored-by: John Villalovos <john@sodarock.com>
+
+### Continuous Integration
+
+- Don't fail CI if unable to upload the code coverage data
+  ([`d5b3744`](https://github.com/python-gitlab/python-gitlab/commit/d5b3744c26c8c78f49e69da251cd53da70b180b3))
+
+If a CI job can't upload coverage results to codecov.com it causes the CI to fail and code can't be
+  merged.
+
+### Documentation
+
+- Update project access token API reference link
+  ([`73ae955`](https://github.com/python-gitlab/python-gitlab/commit/73ae9559dc7f4fba5c80862f0f253959e60f7a0c))
+
+- **cli**: Make examples more easily navigable by generating TOC
+  ([`f33c523`](https://github.com/python-gitlab/python-gitlab/commit/f33c5230cb25c9a41e9f63c0846c1ecba7097ee7))
+
+### Features
+
+- Add support for Group Access Token API
+  ([`c01b7c4`](https://github.com/python-gitlab/python-gitlab/commit/c01b7c494192c5462ec673848287ef2a5c9bd737))
+
+See https://docs.gitlab.com/ee/api/group_access_tokens.html
+
+- Add support for Groups API method `transfer()`
+  ([`0007006`](https://github.com/python-gitlab/python-gitlab/commit/0007006c184c64128caa96b82dafa3db0ea1101f))
+
+- **api**: Add `project.transfer()` and deprecate `transfer_project()`
+  ([`259668a`](https://github.com/python-gitlab/python-gitlab/commit/259668ad8cb54348e4a41143a45f899a222d2d35))
+
+- **api**: Return result from `SaveMixin.save()`
+  ([`e6258a4`](https://github.com/python-gitlab/python-gitlab/commit/e6258a4193a0e8d0c3cf48de15b926bebfa289f3))
+
+Return the new object data when calling `SaveMixin.save()`.
+
+Also remove check for `None` value when calling `self.manager.update()` as that method only returns
+  a dictionary.
+
+Closes: #1081
+
+### Testing
+
+- **groups**: Enable group transfer tests
+  ([`57bb67a`](https://github.com/python-gitlab/python-gitlab/commit/57bb67ae280cff8ac6e946cd3f3797574a574f4a))
+
+
+## v3.0.0 (2022-01-05)
+
+### Bug Fixes
+
+- Handle situation where GitLab does not return values
+  ([`cb824a4`](https://github.com/python-gitlab/python-gitlab/commit/cb824a49af9b0d155b89fe66a4cfebefe52beb7a))
+
+If a query returns more than 10,000 records than the following values are NOT returned:
+  x.total_pages x.total
+
+Modify the code to allow no value to be set for these values. If there is not a value returned the
+  functions will now return None.
+
+Update unit test so no longer `xfail`
+
+https://docs.gitlab.com/ee/user/gitlab_com/index.html#pagination-response-headers
+
+Closes #1686
+
+- Raise error if there is a 301/302 redirection
+  ([`d56a434`](https://github.com/python-gitlab/python-gitlab/commit/d56a4345c1ae05823b553e386bfa393541117467))
+
+Before we raised an error if there was a 301, 302 redirect but only from an http URL to an https
+  URL. But we didn't raise an error for any other redirects.
+
+This caused two problems:
+
+1. PUT requests that are redirected get changed to GET requests which don't perform the desired
+  action but raise no error. This is because the GET response succeeds but since it wasn't a PUT it
+  doesn't update. See issue: https://github.com/python-gitlab/python-gitlab/issues/1432 2. POST
+  requests that are redirected also got changed to GET requests. They also caused hard to debug
+  tracebacks for the user. See issue: https://github.com/python-gitlab/python-gitlab/issues/1477
+
+Correct this by always raising a RedirectError exception and improve the exception message to let
+  them know what was redirected.
+
+Closes: #1485
+
+Closes: #1432
+
+Closes: #1477
+
+- Stop encoding '.' to '%2E'
+  ([`702e41d`](https://github.com/python-gitlab/python-gitlab/commit/702e41dd0674e76b292d9ea4f559c86f0a99edfe))
+
+Forcing the encoding of '.' to '%2E' causes issues. It also goes against the RFC:
+  https://datatracker.ietf.org/doc/html/rfc3986.html#section-2.3
+
+From the RFC: For consistency, percent-encoded octets in the ranges of ALPHA (%41-%5A and %61-%7A),
+  DIGIT (%30-%39), hyphen (%2D), period (%2E), underscore (%5F), or tilde (%7E) should not be
+  created by URI producers...
+
+Closes #1006 Related #1356 Related #1561
+
+BREAKING CHANGE: stop encoding '.' to '%2E'. This could potentially be a breaking change for users
+  who have incorrectly configured GitLab servers which don't handle period '.' characters correctly.
+
+- **api**: Delete invalid 'project-runner get' command
+  ([#1628](https://github.com/python-gitlab/python-gitlab/pull/1628),
+  [`905781b`](https://github.com/python-gitlab/python-gitlab/commit/905781bed2afa33634b27842a42a077a160cffb8))
+
+* fix(api): delete 'group-runner get' and 'group-runner delete' commands
+
+Co-authored-by: Léo GATELLIER <git@leogatellier.fr>
+
+- **api**: Replace deprecated attribute in delete_in_bulk()
+  ([#1536](https://github.com/python-gitlab/python-gitlab/pull/1536),
+  [`c59fbdb`](https://github.com/python-gitlab/python-gitlab/commit/c59fbdb0e9311fa84190579769e3c5c6aeb07fe5))
+
+BREAKING CHANGE: The deprecated `name_regex` attribute has been removed in favor of
+  `name_regex_delete`. (see https://gitlab.com/gitlab-org/gitlab/-/commit/ce99813cf54)
+
+- **build**: Do not include docs in wheel package
+  ([`68a97ce`](https://github.com/python-gitlab/python-gitlab/commit/68a97ced521051afb093cf4fb6e8565d9f61f708))
+
+- **build**: Do not package tests in wheel
+  ([`969dccc`](https://github.com/python-gitlab/python-gitlab/commit/969dccc084e833331fcd26c2a12ddaf448575ab4))
+
+- **objects**: Rename confusing `to_project_id` argument
+  ([`ce4bc0d`](https://github.com/python-gitlab/python-gitlab/commit/ce4bc0daef355e2d877360c6e496c23856138872))
+
+BREAKING CHANGE: rename confusing `to_project_id` argument in transfer_project to `project_id`
+  (`--project-id` in CLI). This is used for the source project, not for the target namespace.
+
+### Chores
+
+- Add .env as a file that search tools should not ignore
+  ([`c9318a9`](https://github.com/python-gitlab/python-gitlab/commit/c9318a9f73c532bee7ba81a41de1fb521ab25ced))
+
+The `.env` file was not set as a file that should not be ignored by search tools. We want to have
+  the search tools search any `.env` files.
+
+- Add and document optional parameters for get MR
+  ([`bfa3dbe`](https://github.com/python-gitlab/python-gitlab/commit/bfa3dbe516cfa8824b720ba4c52dd05054a855d7))
+
+Add and document (some of the) optional parameters that can be done for a
+  `project.merge_requests.get()`
+
+Closes #1775
+
+- Add get() methods for GetWithoutIdMixin based classes
+  ([`d27c50a`](https://github.com/python-gitlab/python-gitlab/commit/d27c50ab9d55dd715a7bee5b0c61317f8565c8bf))
+
+Add the get() methods for the GetWithoutIdMixin based classes.
+
+Update the tests/meta/test_ensure_type_hints.py tests to check to ensure that the get methods are
+  defined with the correct return type.
+
+- Add initial pylint check
+  ([`041091f`](https://github.com/python-gitlab/python-gitlab/commit/041091f37f9ab615e121d5aafa37bf23ef72ba13))
+
+Initial pylint check is added. A LONG list of disabled checks is also added. In the future we should
+  work through the list and resolve the errors or disable them on a more granular level.
+
+- Add Python 3.11 testing
+  ([`b5ec192`](https://github.com/python-gitlab/python-gitlab/commit/b5ec192157461f7feb326846d4323c633658b861))
+
+Add a unit test for Python 3.11. This will use the latest version of Python 3.11 that is available
+  from https://github.com/actions/python-versions/
+
+At this time it is 3.11.0-alpha.2 but will move forward over time until the final 3.11 release and
+  updates. So 3.11.0, 3.11.1, ... will be matched.
+
+- Add running unit tests on windows/macos
+  ([`ad5d60c`](https://github.com/python-gitlab/python-gitlab/commit/ad5d60c305857a8e8c06ba4f6db788bf918bb63f))
+
+Add running the unit tests on windows-latest and macos-latest with Python 3.10.
+
+- Add test case to show branch name with period works
+  ([`ea97d7a`](https://github.com/python-gitlab/python-gitlab/commit/ea97d7a68dd92c6f43dd1f307d63b304137315c4))
+
+Add a test case to show that a branch name with a period can be fetched with a `get()`
+
+Closes: #1715
+
+- Add type hints for gitlab/v4/objects/commits.py
+  ([`dc096a2`](https://github.com/python-gitlab/python-gitlab/commit/dc096a26f72afcebdac380675749a6991aebcd7c))
+
+- Add type-hints to gitlab/v4/objects/epics.py
+  ([`d4adf8d`](https://github.com/python-gitlab/python-gitlab/commit/d4adf8dfd2879b982ac1314e89df76cb61f2dbf9))
+
+- Add type-hints to gitlab/v4/objects/files.py
+  ([`0c22bd9`](https://github.com/python-gitlab/python-gitlab/commit/0c22bd921bc74f48fddd0ff7d5e7525086264d54))
+
+- Add type-hints to gitlab/v4/objects/geo_nodes.py
+  ([`13243b7`](https://github.com/python-gitlab/python-gitlab/commit/13243b752fecc54ba8fc0967ba9a223b520f4f4b))
+
+- Add type-hints to gitlab/v4/objects/groups.py
+  ([`94dcb06`](https://github.com/python-gitlab/python-gitlab/commit/94dcb066ef3ff531778ef4efb97824f010b4993f))
+
+* Add type-hints to gitlab/v4/objects/groups.py * Have share() function update object attributes. *
+  Add 'get()' method so that type-checkers will understand that getting a group is of type Group.
+
+- Add type-hints to gitlab/v4/objects/issues.py
+  ([`93e39a2`](https://github.com/python-gitlab/python-gitlab/commit/93e39a2947c442fb91f5c80b34008ca1d27cdf71))
+
+- Add type-hints to gitlab/v4/objects/jobs.py
+  ([`e8884f2`](https://github.com/python-gitlab/python-gitlab/commit/e8884f21cee29a0ce4428ea2c4b893d1ab922525))
+
+- Add type-hints to gitlab/v4/objects/labels.py
+  ([`d04e557`](https://github.com/python-gitlab/python-gitlab/commit/d04e557fb09655a0433363843737e19d8e11c936))
+
+- Add type-hints to gitlab/v4/objects/merge_request_approvals.py
+  ([`cf3a99a`](https://github.com/python-gitlab/python-gitlab/commit/cf3a99a0c4cf3dc51e946bf29dc44c21b3be9dac))
+
+- Add type-hints to gitlab/v4/objects/merge_requests.py
+  ([`f9c0ad9`](https://github.com/python-gitlab/python-gitlab/commit/f9c0ad939154375b9940bf41a7e47caab4b79a12))
+
+* Add type-hints to gitlab/v4/objects/merge_requests.py * Add return value to
+  cancel_merge_when_pipeline_succeeds() function as GitLab docs show it returns a value. * Add
+  return value to approve() function as GitLab docs show it returns a value. * Add 'get()' method so
+  that type-checkers will understand that getting a project merge request is of type
+  ProjectMergeRequest.
+
+- Add type-hints to gitlab/v4/objects/milestones.py
+  ([`8b6078f`](https://github.com/python-gitlab/python-gitlab/commit/8b6078faf02fcf9d966e2b7d1d42722173534519))
+
+- Add type-hints to gitlab/v4/objects/pipelines.py
+  ([`cb3ad6c`](https://github.com/python-gitlab/python-gitlab/commit/cb3ad6ce4e2b4a8a3fd0e60031550484b83ed517))
+
+- Add type-hints to gitlab/v4/objects/repositories.py
+  ([`00d7b20`](https://github.com/python-gitlab/python-gitlab/commit/00d7b202efb3a2234cf6c5ce09a48397a40b8388))
+
+- Add type-hints to gitlab/v4/objects/services.py
+  ([`8da0b75`](https://github.com/python-gitlab/python-gitlab/commit/8da0b758c589f608a6ae4eeb74b3f306609ba36d))
+
+- Add type-hints to gitlab/v4/objects/sidekiq.py
+  ([`a91a303`](https://github.com/python-gitlab/python-gitlab/commit/a91a303e2217498293cf709b5e05930d41c95992))
+
+- Add type-hints to gitlab/v4/objects/snippets.py
+  ([`f256d4f`](https://github.com/python-gitlab/python-gitlab/commit/f256d4f6c675576189a72b4b00addce440559747))
+
+- Add type-hints to gitlab/v4/objects/users.py
+  ([`88988e3`](https://github.com/python-gitlab/python-gitlab/commit/88988e3059ebadd3d1752db60c2d15b7e60e7c46))
+
+Adding type-hints to gitlab/v4/objects/users.py
+
+- Add type-hints to multiple files in gitlab/v4/objects/
+  ([`8b75a77`](https://github.com/python-gitlab/python-gitlab/commit/8b75a7712dd1665d4b3eabb0c4594e80ab5e5308))
+
+Add and/or check type-hints for the following files gitlab.v4.objects.access_requests
+  gitlab.v4.objects.applications gitlab.v4.objects.broadcast_messages gitlab.v4.objects.deployments
+  gitlab.v4.objects.keys gitlab.v4.objects.merge_trains gitlab.v4.objects.namespaces
+  gitlab.v4.objects.pages gitlab.v4.objects.personal_access_tokens
+  gitlab.v4.objects.project_access_tokens gitlab.v4.objects.tags gitlab.v4.objects.templates
+  gitlab.v4.objects.triggers
+
+Add a 'get' method with the correct type for Managers derived from GetMixin.
+
+- Add type-hints to setup.py and check with mypy
+  ([`06184da`](https://github.com/python-gitlab/python-gitlab/commit/06184daafd5010ba40bb39a0768540b7e98bd171))
+
+- Attempt to be more informative for missing attributes
+  ([`1839c9e`](https://github.com/python-gitlab/python-gitlab/commit/1839c9e7989163a5cc9a201241942b7faca6e214))
+
+A commonly reported issue from users on Gitter is that they get an AttributeError for an attribute
+  that should be present. This is often caused due to the fact that they used the `list()` method to
+  retrieve the object and objects retrieved this way often only have a subset of the full data.
+
+Add more details in the AttributeError message that explains the situation to users. This will
+  hopefully allow them to resolve the issue.
+
+Update the FAQ in the docs to add a section discussing the issue.
+
+Closes #1138
+
+- Attempt to fix flaky functional test
+  ([`487b9a8`](https://github.com/python-gitlab/python-gitlab/commit/487b9a875a18bb3b4e0d49237bb7129d2c6dba2f))
+
+Add an additional check to attempt to solve the flakiness of the
+  test_merge_request_should_remove_source_branch() test.
+
+- Check setup.py with mypy
+  ([`77cb7a8`](https://github.com/python-gitlab/python-gitlab/commit/77cb7a8f64f25191d84528cc61e1d246296645c9))
+
+Prior commit 06184daafd5010ba40bb39a0768540b7e98bd171 fixed the type-hints for setup.py. But missed
+  removing 'setup' from the exclude list in pyproject.toml for mypy checks.
+
+Remove 'setup' from the exclude list in pyproject.toml from mypy checks.
+
+- Clean up install docs
+  ([`a5d8b7f`](https://github.com/python-gitlab/python-gitlab/commit/a5d8b7f2a9cf019c82bef1a166d2dc24f93e1992))
+
+- Convert to using type-annotations for managers
+  ([`d8de4dc`](https://github.com/python-gitlab/python-gitlab/commit/d8de4dc373dc608be6cf6ba14a2acc7efd3fa7a7))
+
+Convert our manager usage to be done via type annotations.
+
+Now to define a manager to be used in a RESTObject subclass can simply do: class
+  ExampleClass(CRUDMixin, RESTObject): my_manager: MyManager
+
+Any type-annotation that annotates it to be of type *Manager (with the exception of RESTManager)
+  will cause the manager to be created on the object.
+
+- Correct test_groups.py test
+  ([`9c878a4`](https://github.com/python-gitlab/python-gitlab/commit/9c878a4090ddb9c0ef63d06b57eb0e4926276e2f))
+
+The test was checking twice if the same group3 was not in the returned list. Should have been
+  checking for group3 and group4.
+
+Also added a test that only skipped one group and checked that the group was not in the returned
+  list and a non-skipped group was in the list.
+
+- Create a 'tests/meta/' directory and put test_mro.py in it
+  ([`94feb8a`](https://github.com/python-gitlab/python-gitlab/commit/94feb8a5534d43a464b717275846faa75783427e))
+
+The 'test_mro.py' file is not really a unit test but more of a 'meta' check on the validity of the
+  code base.
+
+- Enable 'warn_redundant_casts' for mypy
+  ([`f40e9b3`](https://github.com/python-gitlab/python-gitlab/commit/f40e9b3517607c95f2ce2735e3b08ffde8d61e5a))
+
+Enable 'warn_redundant_casts'for mypy and resolve one issue.
+
+- Enable mypy for tests/meta/*
+  ([`ba7707f`](https://github.com/python-gitlab/python-gitlab/commit/ba7707f6161463260710bd2b109b172fd63472a1))
+
+- Enable subset of the 'mypy --strict' options that work
+  ([`a86d049`](https://github.com/python-gitlab/python-gitlab/commit/a86d0490cadfc2f9fe5490879a1258cf264d5202))
+
+Enable the subset of the 'mypy --strict' options that work with no changes to the code.
+
+- Enforce type-hints on most files in gitlab/v4/objects/
+  ([`7828ba2`](https://github.com/python-gitlab/python-gitlab/commit/7828ba2fd13c833c118a673bac09b215587ba33b))
+
+* Add type-hints to some of the files in gitlab/v4/objects/ * Fix issues detected when adding
+  type-hints * Changed mypy exclusion to explicitly list the 13 files that have not yet had
+  type-hints added.
+
+- Ensure get() methods have correct type-hints
+  ([`46773a8`](https://github.com/python-gitlab/python-gitlab/commit/46773a82565cef231dc3391c12f296ac307cb95c))
+
+Fix classes which don't have correct 'get()' methods for classes derived from GetMixin.
+
+Add a unit test which verifies that classes have the correct return type in their 'get()' method.
+
+- Ensure reset_gitlab() succeeds
+  ([`0aa0b27`](https://github.com/python-gitlab/python-gitlab/commit/0aa0b272a90b11951f900b290a8154408eace1de))
+
+Ensure reset_gitlab() succeeds by waiting to make sure everything has been deleted as expected. If
+  the timeout is exceeded fail the test.
+
+Not using `wait_for_sidekiq` as it didn't work. During testing I didn't see any sidekiq processes as
+  being busy even though not everything was deleted.
+
+- Fix functional test failure if config present
+  ([`c8256a5`](https://github.com/python-gitlab/python-gitlab/commit/c8256a5933d745f70c7eea0a7d6230b51bac0fbc))
+
+Fix functional test failure if config present and configured with token.
+
+Closes: #1791
+
+- Fix issue with adding type-hints to 'manager' attribute
+  ([`9a451a8`](https://github.com/python-gitlab/python-gitlab/commit/9a451a892d37e0857af5c82c31a96d68ac161738))
+
+When attempting to add type-hints to the the 'manager' attribute into a RESTObject derived class it
+  would break things.
+
+This was because our auto-manager creation code would automatically add the specified annotated
+  manager to the 'manager' attribute. This breaks things.
+
+Now check in our auto-manager creation if our attribute is called 'manager'. If so we ignore it.
+
+- Fix pylint error "expression-not-assigned"
+  ([`a90eb23`](https://github.com/python-gitlab/python-gitlab/commit/a90eb23cb4903ba25d382c37ce1c0839642ba8fd))
+
+Fix pylint error "expression-not-assigned" and remove check from the disabled list.
+
+And I personally think it is much more readable now and is less lines of code.
+
+- Fix renovate setup for gitlab docker image
+  ([`49af15b`](https://github.com/python-gitlab/python-gitlab/commit/49af15b3febda5af877da06c3d8c989fbeede00a))
+
+- Fix type-check issue shown by new requests-types
+  ([`0ee9aa4`](https://github.com/python-gitlab/python-gitlab/commit/0ee9aa4117b1e0620ba3cade10ccb94944754071))
+
+types-requests==2.25.9 changed a type-hint. Update code to handle this change.
+
+- Fix typo in MR documentation
+  ([`2254222`](https://github.com/python-gitlab/python-gitlab/commit/2254222094d218b31a6151049c7a43e19c593a97))
+
+- Fix unit test if config file exists locally
+  ([`c80b3b7`](https://github.com/python-gitlab/python-gitlab/commit/c80b3b75aff53ae228ec05ddf1c1e61d91762846))
+
+Closes #1764
+
+- Generate artifacts for the docs build in the CI
+  ([`85b43ae`](https://github.com/python-gitlab/python-gitlab/commit/85b43ae4a96b72e2f29e36a0aca5321ed78f28d2))
+
+When building the docs store the created documentation as an artifact so that it can be viewed.
+
+This will create a html-docs.zip file which can be downloaded containing the contents of the
+  `build/sphinx/html/` directory. It can be downloaded, extracted, and then viewed. This can be
+  useful in reviewing changes to the documentation.
+
+See https://github.com/actions/upload-artifact for more information on how this works.
+
+- Github workflow: cancel prior running jobs on new push
+  ([`fd81569`](https://github.com/python-gitlab/python-gitlab/commit/fd8156991556706f776c508c373224b54ef4e14f))
+
+If new new push is done to a pull-request, then cancel any already running github workflow jobs in
+  order to conserve resources.
+
+- Have renovate upgrade black version
+  ([#1700](https://github.com/python-gitlab/python-gitlab/pull/1700),
+  [`21228cd`](https://github.com/python-gitlab/python-gitlab/commit/21228cd14fe18897485728a01c3d7103bff7f822))
+
+renovate is not upgrading the `black` package. There is an open issue[1] about this.
+
+Also change .commitlintrc.json to allow 200 character footer lines in the commit message. Otherwise
+  would be forced to split the URL across multiple lines making it un-clickable :(
+
+Use suggested work-arounds from:
+  https://github.com/renovatebot/renovate/issues/7167#issuecomment-904106838
+  https://github.com/scop/bash-completion/blob/e7497f6ee8232065ec11450a52a1f244f345e2c6/renovate.json#L34-L38
+
+[1] https://github.com/renovatebot/renovate/issues/7167
+
+- Improve type-hinting for managers
+  ([`c9b5d3b`](https://github.com/python-gitlab/python-gitlab/commit/c9b5d3bac8f7c1f779dd57653f718dd0fac4db4b))
+
+The 'managers' are dynamically created. This unfortunately means that we don't have any type-hints
+  for them and so editors which understand type-hints won't know that they are valid attributes.
+
+* Add the type-hints for the managers we define. * Add a unit test that makes sure that the
+  type-hints and the '_managers' attribute are kept in sync with each other. * Add unit test that
+  makes sure specified managers in '_managers' have a name ending in 'Managers' to keep with current
+  convention. * Make RESTObject._managers always present with a default value of None. * Fix a
+  type-issue revealed now that mypy knows what the type is
+
+- Remove '# type: ignore' for new mypy version
+  ([`34a5f22`](https://github.com/python-gitlab/python-gitlab/commit/34a5f22c81590349645ce7ba46d4153d6de07d8c))
+
+mypy 0.920 now understands the type of 'http.client.HTTPConnection.debuglevel' so we remove the
+  'type: ignore' comment to make mypy pass
+
+- Remove duplicate/no-op tests from meta/test_ensure_type_hints
+  ([`a2f59f4`](https://github.com/python-gitlab/python-gitlab/commit/a2f59f4e3146b8871a9a1d66ee84295b44321ecb))
+
+Before we were generating 725 tests for the meta/test_ensure_type_hints.py tests. Which isn't a huge
+  concern as it was fairly fast. But when we had a failure we would usually get two failures for
+  each problem as the same test was being run multiple times.
+
+Changed it so that: 1. Don't add tests that are not for *Manager classes 2. Use a set so that we
+  don't have duplicate tests.
+
+After doing that our generated test count in meta/test_ensure_type_hints.py went from 725 to 178
+  tests.
+
+Additionally removed the parsing of `pyproject.toml` to generate files to ignore as we have finished
+  adding type-hints to all files in gitlab/v4/objects/. This also means we no longer use the toml
+  library so remove installation of `types-toml`.
+
+To determine the test count the following command was run: $ tox -e py39 -- -k
+  test_ensure_type_hints
+
+- Remove pytest-console-scripts specific config
+  ([`e80dcb1`](https://github.com/python-gitlab/python-gitlab/commit/e80dcb1dc09851230b00f8eb63e0c78fda060392))
+
+Remove the pytest-console-scripts specific config from the global '[pytest]' config section.
+
+Use the command line option `--script-launch-mode=subprocess`
+
+Closes #1713
+
+- Rename `master` branch to `main`
+  ([`545f8ed`](https://github.com/python-gitlab/python-gitlab/commit/545f8ed24124837bf4e55aa34e185270a4b7aeff))
+
+BREAKING CHANGE: As of python-gitlab 3.0.0, the default branch for development has changed from
+  `master` to `main`.
+
+- Run pre-commit on changes to the config file
+  ([`5f10b3b`](https://github.com/python-gitlab/python-gitlab/commit/5f10b3b96d83033805757d72269ad0a771d797d4))
+
+If .pre-commit-config.yaml or .github/workflows/pre_commit.yml are updated then run pre-commit.
+
+- Set pre-commit mypy args to empty list
+  ([`b67a6ad`](https://github.com/python-gitlab/python-gitlab/commit/b67a6ad1f81dce4670f9820750b411facc01a048))
+
+https://github.com/pre-commit/mirrors-mypy/blob/master/.pre-commit-hooks.yaml
+
+Sets some default args which seem to be interfering with things. Plus we set all of our args in the
+  `pyproject.toml` file.
+
+- Skip a functional test if not using >= py3.9
+  ([`ac9b595`](https://github.com/python-gitlab/python-gitlab/commit/ac9b59591a954504d4e6e9b576b7a43fcb2ddaaa))
+
+One of the tests requires Python 3.9 or higher to run. Mark the test to be skipped if running Python
+  less than 3.9.
+
+- Update version in docker-compose.yml
+  ([`79321aa`](https://github.com/python-gitlab/python-gitlab/commit/79321aa0e33f0f4bd2ebcdad47769a1a6e81cba8))
+
+When running with docker-compose on Ubuntu 20.04 I got the error:
+
+$ docker-compose up ERROR: The Compose file './docker-compose.yml' is invalid because:
+
+networks.gitlab-network value Additional properties are not allowed ('name' was unexpected)
+
+Changing the version in the docker-compose.yml file fro '3' to '3.5' resolved the issue.
+
+- Use constants from gitlab.const module
+  ([`6b8067e`](https://github.com/python-gitlab/python-gitlab/commit/6b8067e668b6a37a19e07d84e9a0d2d2a99b4d31))
+
+Have code use constants from the gitlab.const module instead of from the top-level gitlab module.
+
+- **api**: Temporarily remove topic delete endpoint
+  ([`e3035a7`](https://github.com/python-gitlab/python-gitlab/commit/e3035a799a484f8d6c460f57e57d4b59217cd6de))
+
+It is not yet available upstream.
+
+- **ci**: Add workflow to lock old issues
+  ([`a7d64fe`](https://github.com/python-gitlab/python-gitlab/commit/a7d64fe5696984aae0c9d6d6b1b51877cc4634cf))
+
+- **ci**: Enable renovate for pre-commit
+  ([`1ac4329`](https://github.com/python-gitlab/python-gitlab/commit/1ac432900d0f87bb83c77aa62757f8f819296e3e))
+
+- **ci**: Wait for all coverage jobs before posting comment
+  ([`c7fdad4`](https://github.com/python-gitlab/python-gitlab/commit/c7fdad42f68927d79e0d1963ade3324370b9d0e2))
+
+- **deps**: Update dependency argcomplete to v2
+  ([`c6d7e9a`](https://github.com/python-gitlab/python-gitlab/commit/c6d7e9aaddda2f39262b695bb98ea4d90575fcce))
+
+- **deps**: Update dependency black to v21
+  ([`5bca87c`](https://github.com/python-gitlab/python-gitlab/commit/5bca87c1e3499eab9b9a694c1f5d0d474ffaca39))
+
+- **deps**: Update dependency black to v21.12b0
+  ([`ab841b8`](https://github.com/python-gitlab/python-gitlab/commit/ab841b8c63183ca20b866818ab2f930a5643ba5f))
+
+- **deps**: Update dependency flake8 to v4
+  ([`79785f0`](https://github.com/python-gitlab/python-gitlab/commit/79785f0bee2ef6cc9872f816a78c13583dfb77ab))
+
+- **deps**: Update dependency isort to v5.10.0
+  ([`ae62468`](https://github.com/python-gitlab/python-gitlab/commit/ae6246807004b84d3b2acd609a70ce220a0ecc21))
+
+- **deps**: Update dependency isort to v5.10.1
+  ([`2012975`](https://github.com/python-gitlab/python-gitlab/commit/2012975ea96a1d3924d6be24aaf92a025e6ab45b))
+
+- **deps**: Update dependency mypy to v0.920
+  ([`a519b2f`](https://github.com/python-gitlab/python-gitlab/commit/a519b2ffe9c8a4bb42d6add5117caecc4bf6ec66))
+
+- **deps**: Update dependency mypy to v0.930
+  ([`ccf8190`](https://github.com/python-gitlab/python-gitlab/commit/ccf819049bf2a9e3be0a0af2a727ab53fc016488))
+
+- **deps**: Update dependency requests to v2.27.0
+  ([`f8c3d00`](https://github.com/python-gitlab/python-gitlab/commit/f8c3d009db3aca004bbd64894a795ee01378cd26))
+
+- **deps**: Update dependency sphinx to v4
+  ([`73745f7`](https://github.com/python-gitlab/python-gitlab/commit/73745f73e5180dd21f450ac4d8cbcca19930e549))
+
+- **deps**: Update dependency sphinx to v4.3.0
+  ([`57283fc`](https://github.com/python-gitlab/python-gitlab/commit/57283fca5890f567626235baaf91ca62ae44ff34))
+
+- **deps**: Update dependency sphinx to v4.3.1
+  ([`93a3893`](https://github.com/python-gitlab/python-gitlab/commit/93a3893977d4e3a3e1916a94293e66373b1458fb))
+
+- **deps**: Update dependency sphinx to v4.3.2
+  ([`2210e56`](https://github.com/python-gitlab/python-gitlab/commit/2210e56da57a9e82e6fd2977453b2de4af14bb6f))
+
+- **deps**: Update dependency types-pyyaml to v5.4.10
+  ([`bdb6cb9`](https://github.com/python-gitlab/python-gitlab/commit/bdb6cb932774890752569ebbc86509e011728ae6))
+
+- **deps**: Update dependency types-pyyaml to v6
+  ([`0b53c0a`](https://github.com/python-gitlab/python-gitlab/commit/0b53c0a260ab2ec2c5ddb12ca08bfd21a24f7a69))
+
+- **deps**: Update dependency types-pyyaml to v6.0.1
+  ([`a544cd5`](https://github.com/python-gitlab/python-gitlab/commit/a544cd576c127ba1986536c9ea32daf2a42649d4))
+
+- **deps**: Update dependency types-requests to v2.25.12
+  ([`205ad5f`](https://github.com/python-gitlab/python-gitlab/commit/205ad5fe0934478eb28c014303caa178f5b8c7ec))
+
+- **deps**: Update dependency types-requests to v2.25.9
+  ([`e3912ca`](https://github.com/python-gitlab/python-gitlab/commit/e3912ca69c2213c01cd72728fd669724926fd57a))
+
+- **deps**: Update dependency types-requests to v2.26.0
+  ([`7528d84`](https://github.com/python-gitlab/python-gitlab/commit/7528d84762f03b668e9d63a18a712d7224943c12))
+
+- **deps**: Update dependency types-requests to v2.26.2
+  ([`ac7e329`](https://github.com/python-gitlab/python-gitlab/commit/ac7e32989a1e7b217b448f57bf2943ff56531983))
+
+- **deps**: Update dependency types-setuptools to v57.4.3
+  ([`ec2c68b`](https://github.com/python-gitlab/python-gitlab/commit/ec2c68b0b41ac42a2bca61262a917a969cbcbd09))
+
+- **deps**: Update pre-commit hook alessandrojcm/commitlint-pre-commit-hook to v6
+  ([`fb9110b`](https://github.com/python-gitlab/python-gitlab/commit/fb9110b1849cea8fa5eddf56f1dbfc1c75f10ad9))
+
+- **deps**: Update pre-commit hook psf/black to v21
+  ([`b86e819`](https://github.com/python-gitlab/python-gitlab/commit/b86e819e6395a84755aaf42334b17567a1bed5fd))
+
+- **deps**: Update pre-commit hook pycqa/flake8 to v4
+  ([`98a5592`](https://github.com/python-gitlab/python-gitlab/commit/98a5592ae7246bf927beb3300211007c0fadba2f))
+
+- **deps**: Update pre-commit hook pycqa/isort to v5.10.1
+  ([`8ac4f4a`](https://github.com/python-gitlab/python-gitlab/commit/8ac4f4a2ba901de1ad809e4fc2fe787e37703a50))
+
+- **deps**: Update python docker tag to v3.10
+  ([`b3d6d91`](https://github.com/python-gitlab/python-gitlab/commit/b3d6d91fed4e5b8424e1af9cadb2af5b6cd8162f))
+
+- **deps**: Update typing dependencies
+  ([`1f95613`](https://github.com/python-gitlab/python-gitlab/commit/1f9561314a880048227b6f3ecb2ed59e60200d19))
+
+- **deps**: Update typing dependencies
+  ([`8d4c953`](https://github.com/python-gitlab/python-gitlab/commit/8d4c95358c9e61c1cfb89562252498093f56d269))
+
+- **deps**: Update typing dependencies
+  ([`4170dbe`](https://github.com/python-gitlab/python-gitlab/commit/4170dbe00112378a523b0fdf3208e8fa4bc5ef00))
+
+- **deps**: Update typing dependencies
+  ([`4eb8ec8`](https://github.com/python-gitlab/python-gitlab/commit/4eb8ec874083adcf86a1781c7866f9dd014f6d27))
+
+- **deps**: Upgrade gitlab-ce to 14.3.2-ce.0
+  ([`5a1678f`](https://github.com/python-gitlab/python-gitlab/commit/5a1678f43184bd459132102cc13cf8426fe0449d))
+
+- **deps**: Upgrade mypy pre-commit hook
+  ([`e19e4d7`](https://github.com/python-gitlab/python-gitlab/commit/e19e4d7cdf9cd04359cd3e95036675c81f4e1dc5))
+
+- **docs**: Link to main, not master
+  ([`af0cb4d`](https://github.com/python-gitlab/python-gitlab/commit/af0cb4d18b8bfbc0624ea2771d73544dc1b24b54))
+
+- **docs**: Load autodoc-typehints module
+  ([`bd366ab`](https://github.com/python-gitlab/python-gitlab/commit/bd366ab9e4b552fb29f7a41564cc180a659bba2f))
+
+- **docs**: Use builtin autodoc hints
+  ([`5e9c943`](https://github.com/python-gitlab/python-gitlab/commit/5e9c94313f6714a159993cefb488aca3326e3e66))
+
+- **objects**: Remove non-existing trigger ownership method
+  ([`8dc7f40`](https://github.com/python-gitlab/python-gitlab/commit/8dc7f40044ce8c478769f25a87c5ceb1aa76b595))
+
+- **tests**: Apply review suggestions
+  ([`381c748`](https://github.com/python-gitlab/python-gitlab/commit/381c748415396e0fe54bb1f41a3303bab89aa065))
+
+### Documentation
+
+- Add links to the GitLab API docs
+  ([`e3b5d27`](https://github.com/python-gitlab/python-gitlab/commit/e3b5d27bde3e104e520d976795cbcb1ae792fb05))
+
+Add links to the GitLab API docs for merge_requests.py as it contains code which spans two different
+  API documentation pages.
+
+- Consolidate changelogs and remove v3 API docs
+  ([`90da8ba`](https://github.com/python-gitlab/python-gitlab/commit/90da8ba0342ebd42b8ec3d5b0d4c5fbb5e701117))
+
+- Correct documentation for updating discussion note
+  ([`ee66f4a`](https://github.com/python-gitlab/python-gitlab/commit/ee66f4a777490a47ad915a3014729a9720bf909b))
+
+Closes #1777
+
+- Correct documented return type
+  ([`acabf63`](https://github.com/python-gitlab/python-gitlab/commit/acabf63c821745bd7e43b7cd3d799547b65e9ed0))
+
+repository_archive() returns 'bytes' not 'str'
+
+https://docs.gitlab.com/ee/api/repositories.html#get-file-archive
+
+Fixes: #1584
+
+- Fix a few typos
+  ([`7ea4ddc`](https://github.com/python-gitlab/python-gitlab/commit/7ea4ddc4248e314998fd27eea17c6667f5214d1d))
+
+There are small typos in: - docs/gl_objects/deploy_tokens.rst - gitlab/base.py - gitlab/mixins.py -
+  gitlab/v4/objects/features.py - gitlab/v4/objects/groups.py - gitlab/v4/objects/packages.py -
+  gitlab/v4/objects/projects.py - gitlab/v4/objects/sidekiq.py - gitlab/v4/objects/todos.py
+
+Fixes: - Should read `treatment` rather than `reatment`. - Should read `transferred` rather than
+  `transfered`. - Should read `registered` rather than `registred`. - Should read `occurred` rather
+  than `occured`. - Should read `overridden` rather than `overriden`. - Should read `marked` rather
+  than `maked`. - Should read `instantiate` rather than `instanciate`. - Should read `function`
+  rather than `fonction`.
+
+- Fix API delete key example
+  ([`b31bb05`](https://github.com/python-gitlab/python-gitlab/commit/b31bb05c868793e4f0cb4573dad6bf9ca01ed5d9))
+
+- Only use type annotations for documentation
+  ([`b7dde0d`](https://github.com/python-gitlab/python-gitlab/commit/b7dde0d7aac8dbaa4f47f9bfb03fdcf1f0b01c41))
+
+- Rename documentation files to match names of code files
+  ([`ee3f865`](https://github.com/python-gitlab/python-gitlab/commit/ee3f8659d48a727da5cd9fb633a060a9231392ff))
+
+Rename the merge request related documentation files to match the code files. This will make it
+  easier to find the documentation quickly.
+
+Rename: `docs/gl_objects/mrs.rst -> `docs/gl_objects/merge_requests.rst`
+  `docs/gl_objects/mr_approvals.rst -> `docs/gl_objects/merge_request_approvals.rst`
+
+- Switch to Furo and refresh introduction pages
+  ([`ee6b024`](https://github.com/python-gitlab/python-gitlab/commit/ee6b024347bf8a178be1a0998216f2a24c940cee))
+
+- Update docs to use gitlab.const for constants
+  ([`b3b0b5f`](https://github.com/python-gitlab/python-gitlab/commit/b3b0b5f1da5b9da9bf44eac33856ed6eadf37dd6))
+
+Update the docs to use gitlab.const to access constants.
+
+- Use annotations for return types
+  ([`79e785e`](https://github.com/python-gitlab/python-gitlab/commit/79e785e765f4219fe6001ef7044235b82c5e7754))
+
+- **api**: Clarify job token usage with auth()
+  ([`3f423ef`](https://github.com/python-gitlab/python-gitlab/commit/3f423efab385b3eb1afe59ad12c2da7eaaa11d76))
+
+See issue #1620
+
+- **api**: Document the update method for project variables
+  ([`7992911`](https://github.com/python-gitlab/python-gitlab/commit/7992911896c62f23f25742d171001f30af514a9a))
+
+- **pipelines**: Document take_ownership method
+  ([`69461f6`](https://github.com/python-gitlab/python-gitlab/commit/69461f6982e2a85dcbf95a0b884abd3f4050c1c7))
+
+- **project**: Remove redundant encoding parameter
+  ([`fed613f`](https://github.com/python-gitlab/python-gitlab/commit/fed613f41a298e79a975b7f99203e07e0f45e62c))
+
+### Features
+
+- Add delete on package_file object
+  ([`124667b`](https://github.com/python-gitlab/python-gitlab/commit/124667bf16b1843ae52e65a3cc9b8d9235ff467e))
+
+- Add support for `projects.groups.list()`
+  ([`68ff595`](https://github.com/python-gitlab/python-gitlab/commit/68ff595967a5745b369a93d9d18fef48b65ebedb))
+
+Add support for `projects.groups.list()` endpoint.
+
+Closes #1717
+
+- Add support for `squash_option` in Projects
+  ([`a246ce8`](https://github.com/python-gitlab/python-gitlab/commit/a246ce8a942b33c5b23ac075b94237da09013fa2))
+
+There is an optional `squash_option` parameter which can be used when creating Projects and
+  UserProjects.
+
+Closes #1744
+
+- Allow global retry_transient_errors setup
+  ([`3b1d3a4`](https://github.com/python-gitlab/python-gitlab/commit/3b1d3a41da7e7228f3a465d06902db8af564153e))
+
+`retry_transient_errors` can now be set through the Gitlab instance and global configuration
+
+Documentation for API usage has been updated and missing tests have been added.
+
+- Default to gitlab.com if no URL given
+  ([`8236281`](https://github.com/python-gitlab/python-gitlab/commit/823628153ec813c4490e749e502a47716425c0f1))
+
+BREAKING CHANGE: python-gitlab will now default to gitlab.com if no URL is given
+
+- Remove support for Python 3.6, require 3.7 or higher
+  ([`414009d`](https://github.com/python-gitlab/python-gitlab/commit/414009daebe19a8ae6c36f050dffc690dff40e91))
+
+Python 3.6 is End-of-Life (EOL) as of 2021-12 as stated in https://www.python.org/dev/peps/pep-0494/
+
+By dropping support for Python 3.6 and requiring Python 3.7 or higher it allows python-gitlab to
+  take advantage of new features in Python 3.7, which are documented at:
+  https://docs.python.org/3/whatsnew/3.7.html
+
+Some of these new features that may be useful to python-gitlab are: * PEP 563, postponed evaluation
+  of type annotations. * dataclasses: PEP 557 – Data Classes * importlib.resources * PEP 562,
+  customization of access to module attributes. * PEP 560, core support for typing module and
+  generic types. * PEP 565, improved DeprecationWarning handling
+
+BREAKING CHANGE: As of python-gitlab 3.0.0, Python 3.6 is no longer supported. Python 3.7 or higher
+  is required.
+
+- **api**: Add merge request approval state
+  ([`f41b093`](https://github.com/python-gitlab/python-gitlab/commit/f41b0937aec5f4a5efba44155cc2db77c7124e5e))
+
+Add support for merge request approval state
+
+- **api**: Add merge trains
+  ([`fd73a73`](https://github.com/python-gitlab/python-gitlab/commit/fd73a738b429be0a2642d5b777d5e56a4c928787))
+
+Add support for merge trains
+
+- **api**: Add project label promotion
+  ([`6d7c88a`](https://github.com/python-gitlab/python-gitlab/commit/6d7c88a1fe401d271a34df80943634652195b140))
+
+Adds a mixin that allows the /promote endpoint to be called.
+
+Signed-off-by: Raimund Hook <raimund.hook@exfo.com>
+
+- **api**: Add project milestone promotion
+  ([`f068520`](https://github.com/python-gitlab/python-gitlab/commit/f0685209f88d1199873c1f27d27f478706908fd3))
+
+Adds promotion to Project Milestones
+
+Signed-off-by: Raimund Hook <raimund.hook@exfo.com>
+
+- **api**: Add support for epic notes
+  ([`7f4edb5`](https://github.com/python-gitlab/python-gitlab/commit/7f4edb53e9413f401c859701d8c3bac4a40706af))
+
+Added support for notes on group epics
+
+Signed-off-by: Raimund Hook <raimund.hook@exfo.com>
+
+- **api**: Add support for Topics API
+  ([`e7559bf`](https://github.com/python-gitlab/python-gitlab/commit/e7559bfa2ee265d7d664d7a18770b0a3e80cf999))
+
+- **api**: Support file format for repository archive
+  ([`83dcabf`](https://github.com/python-gitlab/python-gitlab/commit/83dcabf3b04af63318c981317778f74857279909))
+
+- **build**: Officially support and test python 3.10
+  ([`c042ddc`](https://github.com/python-gitlab/python-gitlab/commit/c042ddc79ea872fc8eb8fe4e32f4107a14ffed2d))
+
+- **cli**: Allow options from args and environment variables
+  ([`ca58008`](https://github.com/python-gitlab/python-gitlab/commit/ca58008607385338aaedd14a58adc347fa1a41a0))
+
+BREAKING-CHANGE: The gitlab CLI will now accept CLI arguments
+
+and environment variables for its global options in addition to configuration file options. This may
+  change behavior for some workflows such as running inside GitLab CI and with certain environment
+  variables configured.
+
+- **cli**: Do not require config file to run CLI
+  ([`92a893b`](https://github.com/python-gitlab/python-gitlab/commit/92a893b8e230718436582dcad96175685425b1df))
+
+BREAKING CHANGE: A config file is no longer needed to run the CLI. python-gitlab will default to
+  https://gitlab.com with no authentication if there is no config file provided. python-gitlab will
+  now also only look for configuration in the provided PYTHON_GITLAB_CFG path, instead of merging it
+  with user- and system-wide config files. If the environment variable is defined and the file
+  cannot be opened, python-gitlab will now explicitly fail.
+
+- **docker**: Remove custom entrypoint from image
+  ([`80754a1`](https://github.com/python-gitlab/python-gitlab/commit/80754a17f66ef4cd8469ff0857e0fc592c89796d))
+
+This is no longer needed as all of the configuration is handled by the CLI and can be passed as
+  arguments.
+
+- **objects**: List starred projects of a user
+  ([`47a5606`](https://github.com/python-gitlab/python-gitlab/commit/47a56061421fc8048ee5cceaf47ac031c92aa1da))
+
+- **objects**: Support Create and Revoke personal access token API
+  ([`e19314d`](https://github.com/python-gitlab/python-gitlab/commit/e19314dcc481b045ba7a12dd76abedc08dbdf032))
+
+- **objects**: Support delete package files API
+  ([`4518046`](https://github.com/python-gitlab/python-gitlab/commit/45180466a408cd51c3ea4fead577eb0e1f3fe7f8))
+
+### Refactoring
+
+- Deprecate accessing constants from top-level namespace
+  ([`c0aa0e1`](https://github.com/python-gitlab/python-gitlab/commit/c0aa0e1c9f7d7914e3062fe6503da870508b27cf))
+
+We are planning on adding enumerated constants into gitlab/const.py, but if we do that than they
+  will end up being added to the top-level gitlab namespace. We really want to get users to start
+  using `gitlab.const.` to access the constant values in the future.
+
+Add the currently defined constants to a list that should not change. Use a module level __getattr__
+  function so that we can deprecate access to the top-level constants.
+
+Add a unit test which verifies we generate a warning when accessing the top-level constants.
+
+- Use f-strings for string formatting
+  ([`7925c90`](https://github.com/python-gitlab/python-gitlab/commit/7925c902d15f20abaecdb07af213f79dad91355b))
+
+- Use new-style formatting for named placeholders
+  ([`c0d8810`](https://github.com/python-gitlab/python-gitlab/commit/c0d881064f7c90f6a510db483990776ceb17b9bd))
+
+- **objects**: Remove deprecated branch protect methods
+  ([`9656a16`](https://github.com/python-gitlab/python-gitlab/commit/9656a16f9f34a1aeb8ea0015564bad68ffb39c26))
+
+BREAKING CHANGE: remove deprecated branch protect methods in favor of the more complete protected
+  branches API.
+
+- **objects**: Remove deprecated constants defined in objects
+  ([`3f320af`](https://github.com/python-gitlab/python-gitlab/commit/3f320af347df05bba9c4d0d3bdb714f7b0f7b9bf))
+
+BREAKING CHANGE: remove deprecated constants defined in gitlab.v4.objects, and use only gitlab.const
+  module
+
+- **objects**: Remove deprecated members.all() method
+  ([`4d7b848`](https://github.com/python-gitlab/python-gitlab/commit/4d7b848e2a826c58e91970a1d65ed7d7c3e07166))
+
+BREAKING CHANGE: remove deprecated members.all() method in favor of members_all.list()
+
+- **objects**: Remove deprecated pipelines() method
+  ([`c4f5ec6`](https://github.com/python-gitlab/python-gitlab/commit/c4f5ec6c615e9f83d533a7be0ec19314233e1ea0))
+
+BREAKING CHANGE: remove deprecated pipelines() methods in favor of pipelines.list()
+
+- **objects**: Remove deprecated project.issuesstatistics
+  ([`ca7777e`](https://github.com/python-gitlab/python-gitlab/commit/ca7777e0dbb82b5d0ff466835a94c99e381abb7c))
+
+BREAKING CHANGE: remove deprecated project.issuesstatistics in favor of project.issues_statistics
+
+- **objects**: Remove deprecated tag release API
+  ([`2b8a94a`](https://github.com/python-gitlab/python-gitlab/commit/2b8a94a77ba903ae97228e7ffa3cc2bf6ceb19ba))
+
+BREAKING CHANGE: remove deprecated tag release API. This was removed in GitLab 14.0
+
+### Testing
+
+- Drop httmock dependency in test_gitlab.py
+  ([`c764bee`](https://github.com/python-gitlab/python-gitlab/commit/c764bee191438fc4aa2e52d14717c136760d2f3f))
+
+- Reproduce missing pagination headers in tests
+  ([`501f9a1`](https://github.com/python-gitlab/python-gitlab/commit/501f9a1588db90e6d2c235723ba62c09a669b5d2))
+
+- **api**: Fix current user mail count in newer gitlab
+  ([`af33aff`](https://github.com/python-gitlab/python-gitlab/commit/af33affa4888fa83c31557ae99d7bbd877e9a605))
+
+- **build**: Add smoke tests for sdist & wheel package
+  ([`b8a47ba`](https://github.com/python-gitlab/python-gitlab/commit/b8a47bae3342400a411fb9bf4bef3c15ba91c98e))
+
+- **cli**: Improve basic CLI coverage
+  ([`6b892e3`](https://github.com/python-gitlab/python-gitlab/commit/6b892e3dcb18d0f43da6020b08fd4ba891da3670))
+
+
+## v2.10.1 (2021-08-28)
+
+### Bug Fixes
+
+- **deps**: Upgrade requests to 2.25.0 (see CVE-2021-33503)
+  ([`ce995b2`](https://github.com/python-gitlab/python-gitlab/commit/ce995b256423a0c5619e2a6c0d88e917aad315ba))
+
+- **mixins**: Improve deprecation warning
+  ([`57e0187`](https://github.com/python-gitlab/python-gitlab/commit/57e018772492a8522b37d438d722c643594cf580))
+
+Also note what should be changed
+
+### Chores
+
+- Define root dir in mypy, not tox
+  ([`7a64e67`](https://github.com/python-gitlab/python-gitlab/commit/7a64e67c8ea09c5e4e041cc9d0807f340d0e1310))
+
+- Fix mypy pre-commit hook
+  ([`bd50df6`](https://github.com/python-gitlab/python-gitlab/commit/bd50df6b963af39b70ea2db50fb2f30b55ddc196))
+
+- **deps**: Group typing requirements with mypy additional_dependencies
+  ([`38597e7`](https://github.com/python-gitlab/python-gitlab/commit/38597e71a7dd12751b028f9451587f781f95c18f))
+
+- **deps**: Update codecov/codecov-action action to v2
+  ([`44f4fb7`](https://github.com/python-gitlab/python-gitlab/commit/44f4fb78bb0b5a18a4703b68a9657796bf852711))
+
+- **deps**: Update dependency isort to v5.9.3
+  ([`ab46e31`](https://github.com/python-gitlab/python-gitlab/commit/ab46e31f66c36d882cdae0b02e702b37e5a6ff4e))
+
+- **deps**: Update dependency types-pyyaml to v5.4.7
+  ([`ec8be67`](https://github.com/python-gitlab/python-gitlab/commit/ec8be67ddd37302f31b07185cb4778093e549588))
+
+- **deps**: Update dependency types-pyyaml to v5.4.8
+  ([`2ae1dd7`](https://github.com/python-gitlab/python-gitlab/commit/2ae1dd7d91f4f90123d9dd8ea92c61b38383e31c))
+
+- **deps**: Update dependency types-requests to v2.25.1
+  ([`a2d133a`](https://github.com/python-gitlab/python-gitlab/commit/a2d133a995d3349c9b0919dd03abaf08b025289e))
+
+- **deps**: Update dependency types-requests to v2.25.2
+  ([`4782678`](https://github.com/python-gitlab/python-gitlab/commit/47826789a5f885a87ae139b8c4d8da9d2dacf713))
+
+- **deps**: Update precommit hook pycqa/isort to v5.9.3
+  ([`e1954f3`](https://github.com/python-gitlab/python-gitlab/commit/e1954f355b989007d13a528f1e49e9410256b5ce))
+
+- **deps**: Update typing dependencies
+  ([`34fc210`](https://github.com/python-gitlab/python-gitlab/commit/34fc21058240da564875f746692b3fb4c3f7c4c8))
+
+- **deps**: Update wagoid/commitlint-github-action action to v4
+  ([`ae97196`](https://github.com/python-gitlab/python-gitlab/commit/ae97196ce8f277082ac28fcd39a9d11e464e6da9))
+
+### Documentation
+
+- **mergequests**: Gl.mergequests.list documentation was missleading
+  ([`5b5a7bc`](https://github.com/python-gitlab/python-gitlab/commit/5b5a7bcc70a4ddd621cbd59e134e7004ad2d9ab9))
+
+
+## v2.10.0 (2021-07-28)
+
+### Bug Fixes
+
+- **api**: Do not require Release name for creation
+  ([`98cd03b`](https://github.com/python-gitlab/python-gitlab/commit/98cd03b7a3085356b5f0f4fcdb7dc729b682f481))
+
+Stop requiring a `name` attribute for creating a Release, since a release name has not been required
+  since GitLab 12.5.
+
+### Chores
+
+- **deps**: Update dependency isort to v5.9.2
+  ([`d5dcf1c`](https://github.com/python-gitlab/python-gitlab/commit/d5dcf1cb7e703ec732e12e41d2971726f27a4bdc))
+
+- **deps**: Update dependency requests to v2.26.0
+  ([`d3ea203`](https://github.com/python-gitlab/python-gitlab/commit/d3ea203dc0e4677b7f36c0f80e6a7a0438ea6385))
+
+- **deps**: Update precommit hook pycqa/isort to v5.9.2
+  ([`521cddd`](https://github.com/python-gitlab/python-gitlab/commit/521cdddc5260ef2ba6330822ec96efc90e1c03e3))
+
+### Documentation
+
+- Add example for mr.merge_ref
+  ([`b30b8ac`](https://github.com/python-gitlab/python-gitlab/commit/b30b8ac27d98ed0a45a13775645d77b76e828f95))
+
+Signed-off-by: Matej Focko <mfocko@redhat.com>
+
+- **project**: Add example on getting a single project using name with namespace
+  ([`ef16a97`](https://github.com/python-gitlab/python-gitlab/commit/ef16a979031a77155907f4160e4f5e159d839737))
+
+- **readme**: Move contributing docs to CONTRIBUTING.rst
+  ([`edf49a3`](https://github.com/python-gitlab/python-gitlab/commit/edf49a3d855b1ce4e2bd8a7038b7444ff0ab5fdc))
+
+Move the Contributing section of README.rst to CONTRIBUTING.rst, so it is recognized by GitHub and
+  shown when new contributors make pull requests.
+
+### Features
+
+- **api**: Add `name_regex_keep` attribute in `delete_in_bulk()`
+  ([`e49ff3f`](https://github.com/python-gitlab/python-gitlab/commit/e49ff3f868cbab7ff81115f458840b5f6d27d96c))
+
+- **api**: Add merge_ref for merge requests
+  ([`1e24ab2`](https://github.com/python-gitlab/python-gitlab/commit/1e24ab247cc783ae240e94f6cb379fef1e743a52))
+
+Support merge_ref on merge requests that returns commit of attempted merge of the MR.
+
+Signed-off-by: Matej Focko <mfocko@redhat.com>
+
+### Testing
+
+- **functional**: Add mr.merge_ref tests
+  ([`a9924f4`](https://github.com/python-gitlab/python-gitlab/commit/a9924f48800f57fa8036e3ebdf89d1e04b9bf1a1))
+
+- Add test for using merge_ref on non-merged MR - Add test for using merge_ref on MR with conflicts
+
+Signed-off-by: Matej Focko <mfocko@redhat.com>
+
+
+## v2.9.0 (2021-06-28)
+
+### Chores
+
+- Add new required type packages for mypy
+  ([`a7371e1`](https://github.com/python-gitlab/python-gitlab/commit/a7371e19520325a725813e328004daecf9259dd2))
+
+New version of mypy flagged errors for missing types. Install the recommended type-* packages that
+  resolve the issues.
+
+- Add type-hints to gitlab/v4/objects/projects.py
+  ([`872dd6d`](https://github.com/python-gitlab/python-gitlab/commit/872dd6defd8c299e997f0f269f55926ce51bd13e))
+
+Adding type-hints to gitlab/v4/objects/projects.py
+
+- Skip EE test case in functional tests
+  ([`953f207`](https://github.com/python-gitlab/python-gitlab/commit/953f207466c53c28a877f2a88da9160acef40643))
+
+- **deps**: Update dependency isort to v5.9.1
+  ([`0479dba`](https://github.com/python-gitlab/python-gitlab/commit/0479dba8a26d2588d9616dbeed351b0256f4bf87))
+
+- **deps**: Update dependency mypy to v0.902
+  ([`19c9736`](https://github.com/python-gitlab/python-gitlab/commit/19c9736de06d032569020697f15ea9d3e2b66120))
+
+- **deps**: Update dependency mypy to v0.910
+  ([`02a56f3`](https://github.com/python-gitlab/python-gitlab/commit/02a56f397880b3939b8e737483ac6f95f809ac9c))
+
+- **deps**: Update dependency types-pyyaml to v0.1.8
+  ([`e566767`](https://github.com/python-gitlab/python-gitlab/commit/e56676730d3407efdf4255b3ca7ee13b7c36eb53))
+
+- **deps**: Update dependency types-pyyaml to v0.1.9
+  ([`1f5b3c0`](https://github.com/python-gitlab/python-gitlab/commit/1f5b3c03b2ae451dfe518ed65ec2bec4e80c09d1))
+
+- **deps**: Update dependency types-pyyaml to v5
+  ([`5c22634`](https://github.com/python-gitlab/python-gitlab/commit/5c226343097427b3f45a404db5b78d61143074fb))
+
+- **deps**: Update dependency types-requests to v0.1.11
+  ([`6ba629c`](https://github.com/python-gitlab/python-gitlab/commit/6ba629c71a4cf8ced7060580a6e6643738bc4186))
+
+- **deps**: Update dependency types-requests to v0.1.12
+  ([`f84c2a8`](https://github.com/python-gitlab/python-gitlab/commit/f84c2a885069813ce80c18542fcfa30cc0d9b644))
+
+- **deps**: Update dependency types-requests to v0.1.13
+  ([`c3ddae2`](https://github.com/python-gitlab/python-gitlab/commit/c3ddae239aee6694a09c864158e355675567f3d2))
+
+- **deps**: Update dependency types-requests to v2
+  ([`a81a926`](https://github.com/python-gitlab/python-gitlab/commit/a81a926a0979e3272abfb2dc40d2f130d3a0ba5a))
+
+- **deps**: Update precommit hook pycqa/isort to v5.9.1
+  ([`c57ffe3`](https://github.com/python-gitlab/python-gitlab/commit/c57ffe3958c1475c8c79bb86fc4b101d82350d75))
+
+### Documentation
+
+- Make Gitlab class usable for intersphinx
+  ([`8753add`](https://github.com/python-gitlab/python-gitlab/commit/8753add72061ea01c508a42d16a27388b1d92677))
+
+- **release**: Add update example
+  ([`6254a5f`](https://github.com/python-gitlab/python-gitlab/commit/6254a5ff6f43bd7d0a26dead304465adf1bd0886))
+
+- **tags**: Remove deprecated functions
+  ([`1b1a827`](https://github.com/python-gitlab/python-gitlab/commit/1b1a827dd40b489fdacdf0a15b0e17a1a117df40))
+
+### Features
+
+- **api**: Add group hooks
+  ([`4a7e9b8`](https://github.com/python-gitlab/python-gitlab/commit/4a7e9b86aa348b72925bce3af1e5d988b8ce3439))
+
+- **api**: Add MR pipeline manager in favor of pipelines() method
+  ([`954357c`](https://github.com/python-gitlab/python-gitlab/commit/954357c49963ef51945c81c41fd4345002f9fb98))
+
+- **api**: Add support for creating/editing reviewers in project merge requests
+  ([`676d1f6`](https://github.com/python-gitlab/python-gitlab/commit/676d1f6565617a28ee84eae20e945f23aaf3d86f))
+
+- **api**: Remove responsibility for API inconsistencies for MR reviewers
+  ([`3d985ee`](https://github.com/python-gitlab/python-gitlab/commit/3d985ee8cdd5d27585678f8fbb3eb549818a78eb))
+
+- **release**: Allow to update release
+  ([`b4c4787`](https://github.com/python-gitlab/python-gitlab/commit/b4c4787af54d9db6c1f9e61154be5db9d46de3dd))
+
+Release API now supports PUT.
+
+### Testing
+
+- **releases**: Add unit-tests for release update
+  ([`5b68a5a`](https://github.com/python-gitlab/python-gitlab/commit/5b68a5a73eb90316504d74d7e8065816f6510996))
+
+- **releases**: Integration for release PUT
+  ([`13bf61d`](https://github.com/python-gitlab/python-gitlab/commit/13bf61d07e84cd719931234c3ccbb9977c8f6416))
+
+
+## v2.8.0 (2021-06-10)
+
+### Bug Fixes
+
+- Add a check to ensure the MRO is correct
+  ([`565d548`](https://github.com/python-gitlab/python-gitlab/commit/565d5488b779de19a720d7a904c6fc14c394a4b9))
+
+Add a check to ensure the MRO (Method Resolution Order) is correct for classes in gitlab.v4.objects
+  when doing type-checking.
+
+An example of an incorrect definition: class ProjectPipeline(RESTObject, RefreshMixin,
+  ObjectDeleteMixin): ^^^^^^^^^^ This should be at the end.
+
+Correct way would be: class ProjectPipeline(RefreshMixin, ObjectDeleteMixin, RESTObject): Correctly
+  at the end ^^^^^^^^^^
+
+Also fix classes which have the issue.
+
+- Catch invalid type used to initialize RESTObject
+  ([`c7bcc25`](https://github.com/python-gitlab/python-gitlab/commit/c7bcc25a361f9df440f9c972672e5eec3b057625))
+
+Sometimes we have errors where we don't get a dictionary passed to RESTObject.__init__() method.
+  This breaks things but in confusing ways.
+
+Check in the __init__() method and raise an exception if it occurs.
+
+- Change mr.merge() to use 'post_data'
+  ([`cb6a3c6`](https://github.com/python-gitlab/python-gitlab/commit/cb6a3c672b9b162f7320c532410713576fbd1cdc))
+
+MR https://github.com/python-gitlab/python-gitlab/pull/1121 changed mr.merge() to use 'query_data'.
+  This appears to have been wrong.
+
+From the Gitlab docs they state it should be sent in a payload body
+  https://docs.gitlab.com/ee/api/README.html#request-payload since mr.merge() is a PUT request.
+
+> Request Payload
+
+> API Requests can use parameters sent as query strings or as a > payload body. GET requests usually
+  send a query string, while PUT > or POST requests usually send the payload body
+
+Fixes: #1452
+
+Related to: #1120
+
+- Ensure kwargs are passed appropriately for ObjectDeleteMixin
+  ([`4e690c2`](https://github.com/python-gitlab/python-gitlab/commit/4e690c256fc091ddf1649e48dbbf0b40cc5e6b95))
+
+- Functional project service test
+  ([#1500](https://github.com/python-gitlab/python-gitlab/pull/1500),
+  [`093db9d`](https://github.com/python-gitlab/python-gitlab/commit/093db9d129e0a113995501755ab57a04e461c745))
+
+chore: fix functional project service test
+
+- Iids not working as a list in projects.issues.list()
+  ([`45f806c`](https://github.com/python-gitlab/python-gitlab/commit/45f806c7a7354592befe58a76b7e33a6d5d0fe6e))
+
+Set the 'iids' values as type ListAttribute so it will pass the list as a comma-separated string,
+  instead of a list.
+
+Add a functional test.
+
+Closes: #1407
+
+- **cli**: Add missing list filter for jobs
+  ([`b3d1c26`](https://github.com/python-gitlab/python-gitlab/commit/b3d1c267cbe6885ee41b3c688d82890bb2e27316))
+
+- **cli**: Fix parsing CLI objects to classnames
+  ([`4252070`](https://github.com/python-gitlab/python-gitlab/commit/42520705a97289ac895a6b110d34d6c115e45500))
+
+- **objects**: Add missing group attributes
+  ([`d20ff4f`](https://github.com/python-gitlab/python-gitlab/commit/d20ff4ff7427519c8abccf53e3213e8929905441))
+
+- **objects**: Allow lists for filters for in all objects
+  ([`603a351`](https://github.com/python-gitlab/python-gitlab/commit/603a351c71196a7f516367fbf90519f9452f3c55))
+
+- **objects**: Return server data in cancel/retry methods
+  ([`9fed061`](https://github.com/python-gitlab/python-gitlab/commit/9fed06116bfe5df79e6ac5be86ae61017f9a2f57))
+
+### Chores
+
+- Add a functional test for issue #1120
+  ([`7d66115`](https://github.com/python-gitlab/python-gitlab/commit/7d66115573c6c029ce6aa00e244f8bdfbb907e33))
+
+Going to switch to putting parameters from in the query string to having them in the 'data' body
+  section. Add a functional test to make sure that we don't break anything.
+
+https://github.com/python-gitlab/python-gitlab/issues/1120
+
+- Add a merge_request() pytest fixture and use it
+  ([`8be2838`](https://github.com/python-gitlab/python-gitlab/commit/8be2838a9ee3e2440d066e2c4b77cb9b55fc3da2))
+
+Added a pytest.fixture for merge_request(). Use this fixture in
+  tools/functional/api/test_merge_requests.py
+
+- Add an isort tox environment and run isort in CI
+  ([`dda646e`](https://github.com/python-gitlab/python-gitlab/commit/dda646e8f2ecb733e37e6cffec331b783b64714e))
+
+* Add an isort tox environment * Run the isort tox environment using --check in the Github CI
+
+https://pycqa.github.io/isort/
+
+- Add functional test mr.merge() with long commit message
+  ([`cd5993c`](https://github.com/python-gitlab/python-gitlab/commit/cd5993c9d638c2a10879d7e3ac36db06df867e54))
+
+Functional test to show that https://github.com/python-gitlab/python-gitlab/issues/1452 is fixed.
+
+Added a functional test to ensure that we can use large commit message (10_000+ bytes) in mr.merge()
+
+Related to: #1452
+
+- Add missing linters to pre-commit and pin versions
+  ([`85bbd1a`](https://github.com/python-gitlab/python-gitlab/commit/85bbd1a5db5eff8a8cea63b2b192aae66030423d))
+
+- Add missing optional create parameter for approval_rules
+  ([`06a6001`](https://github.com/python-gitlab/python-gitlab/commit/06a600136bdb33bdbd84233303652afb36fb8a1b))
+
+Add missing optional create parameter ('protected_branch_ids') to the project approvalrules.
+
+https://docs.gitlab.com/ee/api/merge_request_approvals.html#create-project-level-rule
+
+- Add type-hints to gitlab/v4/cli.py
+  ([`2673af0`](https://github.com/python-gitlab/python-gitlab/commit/2673af0c09a7c5669d8f62c3cc42f684a9693a0f))
+
+* Add type-hints to gitlab/v4/cli.py * Add required type-hints to other files based on adding
+  type-hints to gitlab/v4/cli.py
+
+- Apply suggestions
+  ([`fe7d19d`](https://github.com/python-gitlab/python-gitlab/commit/fe7d19de5aeba675dcb06621cf36ab4169391158))
+
+- Apply typing suggestions
+  ([`a11623b`](https://github.com/python-gitlab/python-gitlab/commit/a11623b1aa6998e6520f3975f0f3f2613ceee5fb))
+
+Co-authored-by: John Villalovos <john@sodarock.com>
+
+- Clean up tox, pre-commit and requirements
+  ([`237b97c`](https://github.com/python-gitlab/python-gitlab/commit/237b97ceb0614821e59ea041f43a9806b65cdf8c))
+
+- Correct a type-hint
+  ([`046607c`](https://github.com/python-gitlab/python-gitlab/commit/046607cf7fd95c3d25f5af9383fdf10a5bba42c1))
+
+- Fix import ordering using isort
+  ([`f3afd34`](https://github.com/python-gitlab/python-gitlab/commit/f3afd34260d681bbeec974b67012b90d407b7014))
+
+Fix the import ordering using isort.
+
+https://pycqa.github.io/isort/
+
+- Have black run at the top-level
+  ([`429d6c5`](https://github.com/python-gitlab/python-gitlab/commit/429d6c55602f17431201de17e63cdb2c68ac5d73))
+
+This will ensure everything is formatted with black, including setup.py.
+
+- Have flake8 check the entire project
+  ([`ab343ef`](https://github.com/python-gitlab/python-gitlab/commit/ab343ef6da708746aa08a972b461a5e51d898f8b))
+
+Have flake8 run at the top-level of the projects instead of just the gitlab directory.
+
+- Make certain dotfiles searchable by ripgrep
+  ([`e4ce078`](https://github.com/python-gitlab/python-gitlab/commit/e4ce078580f7eac8cf1c56122e99be28e3830247))
+
+By explicitly NOT excluding the dotfiles we care about to the .gitignore file we make those files
+  searchable by tools like ripgrep.
+
+By default dotfiles are ignored by ripgrep and other search tools (not grep)
+
+- Make Get.*Mixin._optional_get_attrs always present
+  ([`3c1a0b3`](https://github.com/python-gitlab/python-gitlab/commit/3c1a0b3ba1f529fab38829c9d355561fd36f4f5d))
+
+Always create GetMixin/GetWithoutIdMixin._optional_get_attrs attribute with a default value of
+  tuple()
+
+This way we don't need to use hasattr() and we will know the type of the attribute.
+
+- Move 'gitlab/tests/' dir to 'tests/unit/'
+  ([`1ac0722`](https://github.com/python-gitlab/python-gitlab/commit/1ac0722bc086b18c070132a0eb53747bbdf2ce0a))
+
+Move the 'gitlab/tests/' directory to 'tests/unit/' so we have all the tests located under the
+  'tests/' directory.
+
+- Mypy: Disallow untyped definitions
+  ([`6aef2da`](https://github.com/python-gitlab/python-gitlab/commit/6aef2dadf715e601ae9c302be0ad9958345a97f2))
+
+Be more strict and don't allow untyped definitions on the files we check.
+
+Also this adds type-hints for two of the decorators so that now functions/methods decorated by them
+  will have their types be revealed correctly.
+
+- Remove commented-out print
+  ([`0357c37`](https://github.com/python-gitlab/python-gitlab/commit/0357c37fb40fb6aef175177fab98d0eadc26b667))
+
+- Rename 'tools/functional/' to 'tests/functional/'
+  ([`502715d`](https://github.com/python-gitlab/python-gitlab/commit/502715d99e02105c39b2c5cf0e7457b3256eba0d))
+
+Rename the 'tools/functional/' directory to 'tests/functional/'
+
+This makes more sense as these are functional tests and not tools.
+
+This was dicussed in: https://github.com/python-gitlab/python-gitlab/discussions/1468
+
+- Simplify functional tests
+  ([`df9b5f9`](https://github.com/python-gitlab/python-gitlab/commit/df9b5f9226f704a603a7e49c78bc4543b412f898))
+
+Add a helper function to have less code duplication in the functional testing.
+
+- Sync create and update attributes for Projects
+  ([`0044bd2`](https://github.com/python-gitlab/python-gitlab/commit/0044bd253d86800a7ea8ef0a9a07e965a65cc6a5))
+
+Sync the create attributes with: https://docs.gitlab.com/ee/api/projects.html#create-project
+
+Sync the update attributes with documentation at:
+  https://docs.gitlab.com/ee/api/projects.html#edit-project
+
+As a note the ordering of the attributes was done to match the ordering of the attributes in the
+  documentation.
+
+Closes: #1497
+
+- Use built-in function issubclass() instead of getmro()
+  ([`81f6386`](https://github.com/python-gitlab/python-gitlab/commit/81f63866593a0486b03a4383d87ef7bc01f4e45f))
+
+Code was using inspect.getmro() to replicate the functionality of the built-in function issubclass()
+
+Switch to using issubclass()
+
+- **ci**: Automate releases
+  ([`0ef497e`](https://github.com/python-gitlab/python-gitlab/commit/0ef497e458f98acee36529e8bda2b28b3310de69))
+
+- **ci**: Ignore .python-version from pyenv
+  ([`149953d`](https://github.com/python-gitlab/python-gitlab/commit/149953dc32c28fe413c9f3a0066575caeab12bc8))
+
+- **ci**: Ignore debug and type_checking in coverage
+  ([`885b608`](https://github.com/python-gitlab/python-gitlab/commit/885b608194a55bd60ef2a2ad180c5caa8f15f8d2))
+
+- **ci**: Use admin PAT for release workflow
+  ([`d175d41`](https://github.com/python-gitlab/python-gitlab/commit/d175d416d5d94f4806f4262e1f11cfee99fb0135))
+
+- **deps**: Update dependency docker-compose to v1.29.2
+  ([`fc241e1`](https://github.com/python-gitlab/python-gitlab/commit/fc241e1ffa995417a969354e37d8fefc21bb4621))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.11.2-ce.0
+  ([`434d15d`](https://github.com/python-gitlab/python-gitlab/commit/434d15d1295187d1970ebef01f4c8a44a33afa31))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.11.3-ce.0
+  ([`f0b52d8`](https://github.com/python-gitlab/python-gitlab/commit/f0b52d829db900e98ab93883b20e6bd8062089c6))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.11.4-ce.0
+  ([`4223269`](https://github.com/python-gitlab/python-gitlab/commit/4223269608c2e58b837684d20973e02eb70e04c9))
+
+- **deps**: Update precommit hook alessandrojcm/commitlint-pre-commit-hook to v5
+  ([`9ff349d`](https://github.com/python-gitlab/python-gitlab/commit/9ff349d21ed40283d60692af5d19d86ed7e72958))
+
+- **docs**: Fix import order for readthedocs build
+  ([`c3de1fb`](https://github.com/python-gitlab/python-gitlab/commit/c3de1fb8ec17f5f704a19df4a56a668570e6fe0a))
+
+### Code Style
+
+- Clean up test run config
+  ([`dfa40c1`](https://github.com/python-gitlab/python-gitlab/commit/dfa40c1ef85992e85c1160587037e56778ab49c0))
+
+### Documentation
+
+- Fail on warnings during sphinx build
+  ([`cbd4d52`](https://github.com/python-gitlab/python-gitlab/commit/cbd4d52b11150594ec29b1ce52348c1086a778c8))
+
+This is useful when docs aren't included in the toctree and don't show up on RTD.
+
+- Fix typo in http_delete docstring
+  ([`5226f09`](https://github.com/python-gitlab/python-gitlab/commit/5226f095c39985d04c34e7703d60814e74be96f8))
+
+- **api**: Add behavior in local attributes when updating objects
+  ([`38f65e8`](https://github.com/python-gitlab/python-gitlab/commit/38f65e8e9994f58bdc74fe2e0e9b971fc3edf723))
+
+### Features
+
+- Add code owner approval as attribute
+  ([`fdc46ba`](https://github.com/python-gitlab/python-gitlab/commit/fdc46baca447e042d3b0a4542970f9758c62e7b7))
+
+The python API was missing the field code_owner_approval_required as implemented in the GitLab REST
+  API.
+
+- Add feature to get inherited member for project/group
+  ([`e444b39`](https://github.com/python-gitlab/python-gitlab/commit/e444b39f9423b4a4c85cdb199afbad987df026f1))
+
+- Add keys endpoint
+  ([`a81525a`](https://github.com/python-gitlab/python-gitlab/commit/a81525a2377aaed797af0706b00be7f5d8616d22))
+
+- Add support for lists of integers to ListAttribute
+  ([`115938b`](https://github.com/python-gitlab/python-gitlab/commit/115938b3e5adf9a2fb5ecbfb34d9c92bf788035e))
+
+Previously ListAttribute only support lists of integers. Now be more flexible and support lists of
+  items which can be coerced into strings, for example integers.
+
+This will help us fix issue #1407 by using ListAttribute for the 'iids' field.
+
+- Indicate that we are a typed package
+  ([`e4421ca`](https://github.com/python-gitlab/python-gitlab/commit/e4421caafeeb0236df19fe7b9233300727e1933b))
+
+By adding the file: py.typed it indicates that python-gitlab is a typed package and contains
+  type-hints.
+
+https://www.python.org/dev/peps/pep-0561/
+
+- **api**: Add deployment mergerequests interface
+  ([`fbbc0d4`](https://github.com/python-gitlab/python-gitlab/commit/fbbc0d400015d7366952a66e4401215adff709f0))
+
+- **objects**: Add pipeline test report support
+  ([`ee9f96e`](https://github.com/python-gitlab/python-gitlab/commit/ee9f96e61ab5da0ecf469c21cccaafc89130a896))
+
+- **objects**: Add support for billable members
+  ([`fb0b083`](https://github.com/python-gitlab/python-gitlab/commit/fb0b083a0e536a6abab25c9ad377770cc4290fe9))
+
+- **objects**: Add support for descendant groups API
+  ([`1b70580`](https://github.com/python-gitlab/python-gitlab/commit/1b70580020825adf2d1f8c37803bc4655a97be41))
+
+- **objects**: Add support for generic packages API
+  ([`79d88bd`](https://github.com/python-gitlab/python-gitlab/commit/79d88bde9e5e6c33029e4a9f26c97404e6a7a874))
+
+- **objects**: Add support for Group wikis
+  ([#1484](https://github.com/python-gitlab/python-gitlab/pull/1484),
+  [`74f5e62`](https://github.com/python-gitlab/python-gitlab/commit/74f5e62ef5bfffc7ba21494d05dbead60b59ecf0))
+
+feat(objects): add support for Group wikis
+
+- **objects**: Support all issues statistics endpoints
+  ([`f731707`](https://github.com/python-gitlab/python-gitlab/commit/f731707f076264ebea65afc814e4aca798970953))
+
+### Testing
+
+- **api**: Fix issues test
+  ([`8e5b0de`](https://github.com/python-gitlab/python-gitlab/commit/8e5b0de7d9b1631aac4e9ac03a286dfe80675040))
+
+Was incorrectly using the issue 'id' vs 'iid'.
+
+- **cli**: Add more real class scenarios
+  ([`8cf5031`](https://github.com/python-gitlab/python-gitlab/commit/8cf5031a2caf2f39ce920c5f80316cc774ba7a36))
+
+- **cli**: Replace assignment expression
+  ([`11ae11b`](https://github.com/python-gitlab/python-gitlab/commit/11ae11bfa5f9fcb903689805f8d35b4d62ab0c90))
+
+This is a feature added in 3.8, removing it allows for the test to run with lower python versions.
+
+- **functional**: Add test for skip_groups list filter
+  ([`a014774`](https://github.com/python-gitlab/python-gitlab/commit/a014774a6a2523b73601a1930c44ac259d03a50e))
+
+- **functional**: Explicitly remove deploy tokens on reset
+  ([`19a55d8`](https://github.com/python-gitlab/python-gitlab/commit/19a55d80762417311dcebde3f998f5ebc7e78264))
+
+Deploy tokens would remain in the instance if the respective project or group was deleted without
+  explicitly revoking the deploy tokens first.
+
+- **functional**: Force delete users on reset
+  ([`8f81456`](https://github.com/python-gitlab/python-gitlab/commit/8f814563beb601715930ed3b0f89c3871e6e2f33))
+
+Timing issues between requesting group deletion and GitLab enacting that deletion resulted in errors
+  while attempting to delete a user which was the sole owner of said group (see: test_groups). Pass
+  the 'hard_delete' parameter to ensure user deletion.
+
+- **functional**: Optionally keep containers running post-tests
+  ([`4c475ab`](https://github.com/python-gitlab/python-gitlab/commit/4c475abe30c36217da920477f3748e26f3395365))
+
+Additionally updates token creation to make use of `first_or_create()`, to avoid errors from the
+  script caused by GitLab constraints preventing duplicate tokens with the same value.
+
+- **functional**: Start tracking functional test coverage
+  ([`f875786`](https://github.com/python-gitlab/python-gitlab/commit/f875786ce338b329421f772b181e7183f0fcb333))
+
+
+## v2.7.1 (2021-04-26)
+
+### Bug Fixes
+
+- **files**: Do not url-encode file paths twice
+  ([`8e25cec`](https://github.com/python-gitlab/python-gitlab/commit/8e25cecce3c0a19884a8d231ee1a672b80e94398))
+
+
+## v2.7.0 (2021-04-25)
+
+### Bug Fixes
+
+- Argument type was not a tuple as expected
+  ([`062f8f6`](https://github.com/python-gitlab/python-gitlab/commit/062f8f6a917abc037714129691a845c16b070ff6))
+
+While adding type-hints mypy flagged this as an issue. The third argument to register_custom_action
+  is supposed to be a tuple. It was being passed as a string rather than a tuple of strings.
+
+- Better real life token lookup example
+  ([`9ef8311`](https://github.com/python-gitlab/python-gitlab/commit/9ef83118efde3d0f35d73812ce8398be2c18ebff))
+
+- Checking if RESTManager._from_parent_attrs is set
+  ([`8224b40`](https://github.com/python-gitlab/python-gitlab/commit/8224b4066e84720d7efed3b7891c47af73cc57ca))
+
+Prior to commit 3727cbd21fc40b312573ca8da56e0f6cf9577d08 RESTManager._from_parent_attrs did not
+  exist unless it was explicitly set. But commit 3727cbd21fc40b312573ca8da56e0f6cf9577d08 set it to
+  a default value of {}.
+
+So the checks using hasattr() were no longer valid.
+
+Update the checks to check if RESTManager._from_parent_attrs has a value.
+
+- Correct ProjectFile.decode() documentation
+  ([`b180baf`](https://github.com/python-gitlab/python-gitlab/commit/b180bafdf282cd97e8f7b6767599bc42d5470bfa))
+
+ProjectFile.decode() returns 'bytes' and not 'str'.
+
+Update the method's doc-string and add a type-hint.
+
+ProjectFile.decode() returns the result of a call to base64.b64decode()
+
+The docs for that function state it returns 'bytes':
+  https://docs.python.org/3/library/base64.html#base64.b64decode
+
+Fixes: #1403
+
+- Correct some type-hints in gitlab/mixins.py
+  ([`8bd3124`](https://github.com/python-gitlab/python-gitlab/commit/8bd312404cf647674baea792547705ef1948043d))
+
+Commit baea7215bbbe07c06b2ca0f97a1d3d482668d887 introduced type-hints for gitlab/mixins.py.
+
+After starting to add type-hints to gitlab/v4/objects/users.py discovered a few errors.
+
+Main error was using '=' instead of ':'. For example: _parent = Optional[...] should be _parent:
+  Optional[...]
+
+Resolved those issues.
+
+- Extend wait timeout for test_delete_user()
+  ([`19fde8e`](https://github.com/python-gitlab/python-gitlab/commit/19fde8ed0e794d33471056e2c07539cde70a8699))
+
+Have been seeing intermittent failures of the test_delete_user() functional test. Have made the
+  following changes to hopefully resolve the issue and if it still fails to know better why the
+  failure occurred.
+
+* Extend the wait timeout for test_delete_user() from 30 to 60 tries of 0.5 seconds each.
+
+* Modify wait_for_sidekiq() to return True if sidekiq process terminated. Return False if the
+  timeout expired.
+
+* Modify wait_for_sidekiq() to loop through all processes instead of assuming there is only one
+  process. If all processes are not busy then return.
+
+* Modify wait_for_sidekiq() to sleep at least once before checking for processes being busy.
+
+* Check for True being returned in test_delete_user() call to wait_for_sidekiq()
+
+- Handle tags like debian/2%2.6-21 as identifiers
+  ([`b4dac5c`](https://github.com/python-gitlab/python-gitlab/commit/b4dac5ce33843cf52badeb9faf0f7f52f20a9a6a))
+
+Git refnames are relatively free-form and can contain all sort for special characters, not just `/`
+  and `#`, see http://git-scm.com/docs/git-check-ref-format
+
+In particular, Debian's DEP-14 standard for storing packaging in git repositories mandates the use
+  of the `%` character in tags in some cases like `debian/2%2.6-21`.
+
+Unfortunately python-gitlab currently only escapes `/` to `%2F` and in some cases `#` to `%23`. This
+  means that when using the commit API to retrieve information about the `debian/2%2.6-21` tag only
+  the slash is escaped before being inserted in the URL path and the `%` is left untouched,
+  resulting in something like `/api/v4/projects/123/repository/commits/debian%2F2%2.6-21`. When
+  urllib3 seees that it detects the invalid `%` escape and then urlencodes the whole string,
+  resulting in `/api/v4/projects/123/repository/commits/debian%252F2%252.6-21`, where the original
+  `/` got escaped twice and produced `%252F`.
+
+To avoid the issue, fully urlencode identifiers and parameters to avoid the urllib3 auto-escaping in
+  all cases.
+
+Signed-off-by: Emanuele Aina <emanuele.aina@collabora.com>
+
+- Handling config value in _get_values_from_helper
+  ([`9dfb4cd`](https://github.com/python-gitlab/python-gitlab/commit/9dfb4cd97e6eb5bbfc29935cbb190b70b739cf9f))
+
+- Honor parameter value passed
+  ([`c2f8f0e`](https://github.com/python-gitlab/python-gitlab/commit/c2f8f0e7db9529e1f1f32d790a67d1e20d2fe052))
+
+Gitlab allows setting the defaults for MR to delete the source. Also the inline help of the CLI
+  suggest that a boolean is expected, but no matter what value you set, it will always delete.
+
+- Let the homedir be expanded in path of helper
+  ([`fc7387a`](https://github.com/python-gitlab/python-gitlab/commit/fc7387a0a6039bc58b2a741ac9b73d7068375be7))
+
+- Linting issues and test
+  ([`b04dd2c`](https://github.com/python-gitlab/python-gitlab/commit/b04dd2c08b69619bb58832f40a4c4391e350a735))
+
+- Make secret helper more user friendly
+  ([`fc2798f`](https://github.com/python-gitlab/python-gitlab/commit/fc2798fc31a08997c049f609c19dd4ab8d75964e))
+
+- Only add query_parameters to GitlabList once
+  ([`ca2c3c9`](https://github.com/python-gitlab/python-gitlab/commit/ca2c3c9dee5dc61ea12af5b39d51b1606da32f9c))
+
+Fixes #1386
+
+- Only append kwargs as query parameters
+  ([`b9ecc9a`](https://github.com/python-gitlab/python-gitlab/commit/b9ecc9a8c5d958bd7247946c4e8d29c18163c578))
+
+Some arguments to `http_request` were being read from kwargs, but kwargs is where this function
+  creates query parameters from, by default. In the absence of a `query_parameters` param, the
+  function would construct URLs with query parameters such as `retry_transient_errors=True` despite
+  those parameters having no meaning to the API to which the request was sent.
+
+This change names those arguments that are specific to `http_request` so that they do not end up as
+  query parameters read from kwargs.
+
+- Remove duplicate class definitions in v4/objects/users.py
+  ([`7c4e625`](https://github.com/python-gitlab/python-gitlab/commit/7c4e62597365e8227b8b63ab8ba0c94cafc7abc8))
+
+The classes UserStatus and UserStatusManager were each declared twice. Remove the duplicate
+  declarations.
+
+- Test_update_group() dependency on ordering
+  ([`e78a8d6`](https://github.com/python-gitlab/python-gitlab/commit/e78a8d6353427bad0055f116e94f471997ee4979))
+
+Since there are two groups we can't depend on the one we changed to always be the first one
+  returned.
+
+Instead fetch the group we want and then test our assertion against that group.
+
+- Tox pep8 target, so that it can run
+  ([`f518e87`](https://github.com/python-gitlab/python-gitlab/commit/f518e87b5492f2f3c201d4d723c07c746a385b6e))
+
+Previously running the pep8 target would fail as flake8 was not installed.
+
+Now install flake8 for the pep8 target.
+
+NOTE: Running the pep8 target fails as there are many warnings/errors.
+
+But it does allow us to run it and possibly work on reducing these warnings/errors in the future.
+
+In addition, add two checks to the ignore list as black takes care of formatting. The two checks
+  added to the ignore list are: * E501: line too long * W503: line break before binary operator
+
+- Undefined name errors
+  ([`48ec9e0`](https://github.com/python-gitlab/python-gitlab/commit/48ec9e0f6a2d2da0a24ef8292c70dc441836a913))
+
+Discovered that there were some undefined names.
+
+- Update doc for token helper
+  ([`3ac6fa1`](https://github.com/python-gitlab/python-gitlab/commit/3ac6fa12b37dd33610ef2206ef4ddc3b20d9fd3f))
+
+- Update user's bool data and avatar
+  ([`3ba27ff`](https://github.com/python-gitlab/python-gitlab/commit/3ba27ffb6ae995c27608f84eef0abe636e2e63da))
+
+If we want to update email, avatar and do not send email confirmation change (`skip_reconfirmation`
+  = True), `MultipartEncoder` will try to encode everything except None and bytes. So it tries to
+  encode bools. Casting bool's values to their stringified int representation fix it.
+
+- Wrong variable name
+  ([`15ec41c`](https://github.com/python-gitlab/python-gitlab/commit/15ec41caf74e264d757d2c64b92427f027194b82))
+
+Discovered this when I ran flake8 on the file. Unfortunately I was the one who introduced this wrong
+  variable name :(
+
+- **objects**: Add single get endpoint for instance audit events
+  ([`c3f0a6f`](https://github.com/python-gitlab/python-gitlab/commit/c3f0a6f158fbc7d90544274b9bf09d5ac9ac0060))
+
+- **types**: Prevent __dir__ from producing duplicates
+  ([`5bf7525`](https://github.com/python-gitlab/python-gitlab/commit/5bf7525d2d37968235514d1b93a403d037800652))
+
+### Chores
+
+- Add _create_attrs & _update_attrs to RESTManager
+  ([`147f05d`](https://github.com/python-gitlab/python-gitlab/commit/147f05d43d302d9a04bc87d957c79ce9e54cdaed))
+
+Add the attributes: _create_attrs and _update_attrs to the RESTManager class. This is so that we
+  stop using getattr() if we don't need to.
+
+This also helps with type-hints being available for these attributes.
+
+- Add additional type-hints for gitlab/base.py
+  ([`ad72ef3`](https://github.com/python-gitlab/python-gitlab/commit/ad72ef35707529058c7c680f334c285746b2f690))
+
+Add type-hints for the variables which are set via self.__dict__
+
+mypy doesn't see them when they are assigned via self.__dict__. So declare them in the class
+  definition.
+
+- Add and fix some type-hints in gitlab/client.py
+  ([`8837207`](https://github.com/python-gitlab/python-gitlab/commit/88372074a703910ba533237e6901e5af4c26c2bd))
+
+Was able to figure out better type-hints for gitlab/client.py
+
+- Add test
+  ([`f8cf1e1`](https://github.com/python-gitlab/python-gitlab/commit/f8cf1e110401dcc6b9b176beb8675513fc1c7d17))
+
+- Add type hints to gitlab/base.py
+  ([`3727cbd`](https://github.com/python-gitlab/python-gitlab/commit/3727cbd21fc40b312573ca8da56e0f6cf9577d08))
+
+- Add type hints to gitlab/base.py:RESTManager
+  ([`9c55593`](https://github.com/python-gitlab/python-gitlab/commit/9c55593ae6a7308176710665f8bec094d4cadc2e))
+
+Add some additional type hints to gitlab/base.py
+
+- Add type hints to gitlab/utils.py
+  ([`acd9294`](https://github.com/python-gitlab/python-gitlab/commit/acd9294fac52a636a016a7a3c14416b10573da28))
+
+- Add type-hints for gitlab/mixins.py
+  ([`baea721`](https://github.com/python-gitlab/python-gitlab/commit/baea7215bbbe07c06b2ca0f97a1d3d482668d887))
+
+* Added type-hints for gitlab/mixins.py * Changed use of filter with a lambda expression to
+  list-comprehension. mypy was not able to understand the previous code. Also list-comprehension is
+  better :)
+
+- Add type-hints to gitlab/cli.py
+  ([`10b7b83`](https://github.com/python-gitlab/python-gitlab/commit/10b7b836d31fbe36a7096454287004b46a7799dd))
+
+- Add type-hints to gitlab/client.py
+  ([`c9e5b4f`](https://github.com/python-gitlab/python-gitlab/commit/c9e5b4f6285ec94d467c7c10c45f4e2d5f656430))
+
+Adding some initial type-hints to gitlab/client.py
+
+- Add type-hints to gitlab/config.py
+  ([`213e563`](https://github.com/python-gitlab/python-gitlab/commit/213e5631b1efce11f8a1419cd77df5d9da7ec0ac))
+
+- Add type-hints to gitlab/const.py
+  ([`a10a777`](https://github.com/python-gitlab/python-gitlab/commit/a10a7777caabd6502d04f3947a317b5b0ac869f2))
+
+- Bump version to 2.7.0
+  ([`34c4052`](https://github.com/python-gitlab/python-gitlab/commit/34c4052327018279c9a75d6b849da74eccc8819b))
+
+- Del 'import *' in gitlab/v4/objects/project_access_tokens.py
+  ([`9efbe12`](https://github.com/python-gitlab/python-gitlab/commit/9efbe1297d8d32419b8f04c3758ca7c83a95f199))
+
+Remove usage of 'import *' in gitlab/v4/objects/project_access_tokens.py.
+
+- Disallow incomplete type defs
+  ([`907634f`](https://github.com/python-gitlab/python-gitlab/commit/907634fe4d0d30706656b8bc56260b5532613e62))
+
+Don't allow a partially annotated function definition. Either none of the function is annotated or
+  all of it must be.
+
+Update code to ensure no-more partially annotated functions.
+
+Update gitlab/cli.py with better type-hints. Changed Tuple[Any, ...] to Tuple[str, ...]
+
+- Explicitly import gitlab.v4.objects/cli
+  ([`233b79e`](https://github.com/python-gitlab/python-gitlab/commit/233b79ed442aac66faf9eb4b0087ea126d6dffc5))
+
+As we only support the v4 Gitlab API, explicitly import gitlab.v4.objects and gitlab.v4.clie instead
+  of dynamically importing it depending on the API version.
+
+This has the added benefit of mypy being able to type check the Gitlab __init__() function as
+  currently it will fail if we enable type checking of __init__() it will fail.
+
+Also, this also helps by not confusing tools like pyinstaller/cx_freeze with dynamic imports so you
+  don't need hooks for standalone executables. And according to https://docs.gitlab.com/ee/api/,
+
+"GraphQL co-exists with the current v4 REST API. If we have a v5 API, this should be a compatibility
+  layer on top of GraphQL."
+
+- Fix E711 error reported by flake8
+  ([`630901b`](https://github.com/python-gitlab/python-gitlab/commit/630901b30911af01da5543ca609bd27bc5a1a44c))
+
+E711: Comparison to none should be 'if cond is none:'
+
+https://www.flake8rules.com/rules/E711.html
+
+- Fix E712 errors reported by flake8
+  ([`83670a4`](https://github.com/python-gitlab/python-gitlab/commit/83670a49a3affd2465f8fcbcc3c26141592c1ccd))
+
+E712: Comparison to true should be 'if cond is true:' or 'if cond:'
+
+https://www.flake8rules.com/rules/E712.html
+
+- Fix E741/E742 errors reported by flake8
+  ([`380f227`](https://github.com/python-gitlab/python-gitlab/commit/380f227a1ecffd5e22ae7aefed95af3b5d830994))
+
+Fixes to resolve errors for: https://www.flake8rules.com/rules/E741.html Do not use variables named
+  'I', 'O', or 'l' (E741)
+
+https://www.flake8rules.com/rules/E742.html Do not define classes named 'I', 'O', or 'l' (E742)
+
+- Fix F401 errors reported by flake8
+  ([`ff21eb6`](https://github.com/python-gitlab/python-gitlab/commit/ff21eb664871904137e6df18308b6e90290ad490))
+
+F401: Module imported but unused
+
+https://www.flake8rules.com/rules/F401.html
+
+- Fix F841 errors reported by flake8
+  ([`40f4ab2`](https://github.com/python-gitlab/python-gitlab/commit/40f4ab20ba0903abd3d5c6844fc626eb264b9a6a))
+
+Local variable name is assigned to but never used
+
+https://www.flake8rules.com/rules/F841.html
+
+- Fix package file test naming
+  ([`8c80268`](https://github.com/python-gitlab/python-gitlab/commit/8c802680ae7d3bff13220a55efeed9ca79104b10))
+
+- Fix typo in mr events
+  ([`c5e6fb3`](https://github.com/python-gitlab/python-gitlab/commit/c5e6fb3bc74c509f35f973e291a7551b2b64dba5))
+
+- Have _create_attrs & _update_attrs be a namedtuple
+  ([`aee1f49`](https://github.com/python-gitlab/python-gitlab/commit/aee1f496c1f414c1e30909767d53ae624fe875e7))
+
+Convert _create_attrs and _update_attrs to use a NamedTuple (RequiredOptional) to help with code
+  readability. Update all code to use the NamedTuple.
+
+- Import audit events in objects
+  ([`35a190c`](https://github.com/python-gitlab/python-gitlab/commit/35a190cfa0902d6a298aba0a3135c5a99edfe0fa))
+
+- Improve type-hints for gitlab/base.py
+  ([`cbd43d0`](https://github.com/python-gitlab/python-gitlab/commit/cbd43d0b4c95e46fc3f1cffddc6281eced45db4a))
+
+Determined the base class for obj_cls and adding type-hints for it.
+
+- Make _types always present in RESTManager
+  ([`924f83e`](https://github.com/python-gitlab/python-gitlab/commit/924f83eb4b5e160bd231efc38e2eea0231fa311f))
+
+We now create _types = {} in RESTManager class.
+
+By making _types always present in RESTManager it makes the code simpler. We no longer have to do:
+  types = getattr(self, "_types", {})
+
+And the type checker now understands the type.
+
+- Make lint happy
+  ([`7a7c9fd`](https://github.com/python-gitlab/python-gitlab/commit/7a7c9fd932def75a2f2c517482784e445d83881a))
+
+- Make lint happy
+  ([`b5f43c8`](https://github.com/python-gitlab/python-gitlab/commit/b5f43c83b25271f7aff917a9ce8826d39ff94034))
+
+- Make lint happy
+  ([`732e49c`](https://github.com/python-gitlab/python-gitlab/commit/732e49c6547c181de8cc56e93b30dc399e87091d))
+
+- Make ListMixin._list_filters always present
+  ([`8933113`](https://github.com/python-gitlab/python-gitlab/commit/89331131b3337308bacb0c4013e80a4809f3952c))
+
+Always create ListMixin._list_filters attribute with a default value of tuple().
+
+This way we don't need to use hasattr() and we will know the type of the attribute.
+
+- Make RESTObject._short_print_attrs always present
+  ([`6d55120`](https://github.com/python-gitlab/python-gitlab/commit/6d551208f4bc68d091a16323ae0d267fbb6003b6))
+
+Always create RESTObject._short_print_attrs with a default value of None.
+
+This way we don't need to use hasattr() and we will know the type of the attribute.
+
+- Put assert statements inside 'if TYPE_CHECKING:'
+  ([`b562458`](https://github.com/python-gitlab/python-gitlab/commit/b562458f063c6be970f58c733fe01ec786798549))
+
+To be safe that we don't assert while running, put the assert statements, which are used by mypy to
+  check that types are correct, inside an 'if TYPE_CHECKING:' block.
+
+Also, instead of asserting that the item is a dict, instead assert that it is not a
+  requests.Response object. Theoretically the JSON could return as a list or dict, though at this
+  time we are assuming a dict.
+
+- Remove import of gitlab.utils from __init__.py
+  ([`39b9183`](https://github.com/python-gitlab/python-gitlab/commit/39b918374b771f1d417196ca74fa04fe3968c412))
+
+Initially when extracting out the gitlab/client.py code we tried to remove this but functional tests
+  failed.
+
+Later we fixed the functional test that was failing, so now remove the unneeded import.
+
+- Remove Python 2 code
+  ([`b5d4e40`](https://github.com/python-gitlab/python-gitlab/commit/b5d4e408830caeef86d4c241ac03a6e8781ef189))
+
+httplib is a Python 2 library. It was renamed to http.client in Python 3.
+
+https://docs.python.org/2.7/library/httplib.html
+
+- Remove unused ALLOWED_KEYSET_ENDPOINTS variable
+  ([`3d5d5d8`](https://github.com/python-gitlab/python-gitlab/commit/3d5d5d8b13fc8405e9ef3e14be1fd8bd32235221))
+
+The variable ALLOWED_KEYSET_ENDPOINTS was added in commit f86ef3bbdb5bffa1348a802e62b281d3f31d33ad.
+
+Then most of that commit was removed in commit e71fe16b47835aa4db2834e98c7ffc6bdec36723, but
+  ALLOWED_KEYSET_ENDPOINTS was missed.
+
+- Remove unused function _construct_url()
+  ([`009d369`](https://github.com/python-gitlab/python-gitlab/commit/009d369f08e46d1e059b98634ff8fe901357002d))
+
+The function _construct_url() was used by the v3 API. All usage of the function was removed in
+  commit fe89b949922c028830dd49095432ba627d330186
+
+- Remove unused function sanitize_parameters()
+  ([`443b934`](https://github.com/python-gitlab/python-gitlab/commit/443b93482e29fecc12fdbd2329427b37b05ba425))
+
+The function sanitize_parameters() was used when the v3 API was in use. Since v3 API support has
+  been removed there are no more users of this function.
+
+- Remove usage of 'from ... import *'
+  ([`c83eaf4`](https://github.com/python-gitlab/python-gitlab/commit/c83eaf4f395300471311a67be34d8d306c2b3861))
+
+In gitlab/v4/objects/*.py remove usage of: * from gitlab.base import * * from gitlab.mixins import *
+
+Change them to: * from gitlab.base import CLASS_NAME * from gitlab.mixins import CLASS_NAME
+
+Programmatically update code to explicitly import needed classes only.
+
+After the change the output of: $ flake8 gitlab/v4/objects/*py | grep 'REST\|Mixin'
+
+Is empty. Before many messages about unable to determine if it was a valid name.
+
+- Remove usage of 'from ... import *' in client.py
+  ([`bf0c8c5`](https://github.com/python-gitlab/python-gitlab/commit/bf0c8c5d123a7ad0587cb97c3aafd97ab2a9dabf))
+
+In gitlab/client.py remove usage of: * from gitlab.const import * * from gitlab.exceptions import *
+
+Change them to: * import gitlab.const * import gitlab.exceptions
+
+Update code to explicitly reference things in gitlab.const and gitlab.exceptions
+
+A flake8 run no longer lists any undefined variables. Before it listed possible undefined variables.
+
+- Remove usage of getattr()
+  ([`2afd18a`](https://github.com/python-gitlab/python-gitlab/commit/2afd18aa28742a3267742859a88be6912a803874))
+
+Remove usage of getattr(self, "_update_uses_post", False)
+
+Instead add it to class and set default value to False.
+
+Add a tests that shows it is set to True for the ProjectMergeRequestApprovalManager and
+  ProjectApprovalManager classes.
+
+- **api**: Move repository endpoints into separate module
+  ([`1ed154c`](https://github.com/python-gitlab/python-gitlab/commit/1ed154c276fb2429d3b45058b9314d6391dbff02))
+
+- **ci**: Deduplicate PR jobs
+  ([`63918c3`](https://github.com/python-gitlab/python-gitlab/commit/63918c364e281f9716885a0f9e5401efcd537406))
+
+- **config**: Allow simple commands without external script
+  ([`91ffb8e`](https://github.com/python-gitlab/python-gitlab/commit/91ffb8e97e213d2f14340b952630875995ecedb2))
+
+- **deps**: Update dependency docker-compose to v1.28.3
+  ([`2358d48`](https://github.com/python-gitlab/python-gitlab/commit/2358d48acbe1c378377fb852b41ec497217d2555))
+
+- **deps**: Update dependency docker-compose to v1.28.4
+  ([`8938484`](https://github.com/python-gitlab/python-gitlab/commit/89384846445be668ca6c861f295297d048cae914))
+
+- **deps**: Update dependency docker-compose to v1.28.5
+  ([`f4ab558`](https://github.com/python-gitlab/python-gitlab/commit/f4ab558f2cd85fe716e24f3aa4ede5db5b06e7c4))
+
+- **deps**: Update dependency docker-compose to v1.28.6
+  ([`46b05d5`](https://github.com/python-gitlab/python-gitlab/commit/46b05d525d0ade6f2aadb6db23fadc85ad48cd3d))
+
+- **deps**: Update dependency docker-compose to v1.29.1
+  ([`a89ec43`](https://github.com/python-gitlab/python-gitlab/commit/a89ec43ee7a60aacd1ac16f0f1f51c4abeaaefef))
+
+- **deps**: Update dependency sphinx to v3.4.3
+  ([`37c992c`](https://github.com/python-gitlab/python-gitlab/commit/37c992c09bfd25f3ddcb026f830f3a79c39cb70d))
+
+- **deps**: Update dependency sphinx to v3.5.0
+  ([`188c5b6`](https://github.com/python-gitlab/python-gitlab/commit/188c5b692fc195361c70f768cc96c57b3686d4b7))
+
+- **deps**: Update dependency sphinx to v3.5.1
+  ([`f916f09`](https://github.com/python-gitlab/python-gitlab/commit/f916f09d3a9cac07246035066d4c184103037026))
+
+- **deps**: Update dependency sphinx to v3.5.2
+  ([`9dee5c4`](https://github.com/python-gitlab/python-gitlab/commit/9dee5c420633bc27e1027344279c47862f7b16da))
+
+- **deps**: Update dependency sphinx to v3.5.4
+  ([`a886d28`](https://github.com/python-gitlab/python-gitlab/commit/a886d28a893ac592b930ce54111d9ae4e90f458e))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.10.0-ce.0
+  ([`5221e33`](https://github.com/python-gitlab/python-gitlab/commit/5221e33768fe1e49456d5df09e3f50b46933c8a4))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.10.1-ce.0
+  ([`1995361`](https://github.com/python-gitlab/python-gitlab/commit/1995361d9a767ad5af5338f4555fa5a3914c7374))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.10.3-ce.0
+  ([`eabe091`](https://github.com/python-gitlab/python-gitlab/commit/eabe091945d3fe50472059431e599117165a815a))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.11.0-ce.0
+  ([`711896f`](https://github.com/python-gitlab/python-gitlab/commit/711896f20ff81826c58f1f86dfb29ad860e1d52a))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.11.1-ce.0
+  ([`3088714`](https://github.com/python-gitlab/python-gitlab/commit/308871496041232f555cf4cb055bf7f4aaa22b23))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.8.2-ce.0
+  ([`7c12038`](https://github.com/python-gitlab/python-gitlab/commit/7c120384762e23562a958ae5b09aac324151983a))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.8.3-ce.0
+  ([`e6c20f1`](https://github.com/python-gitlab/python-gitlab/commit/e6c20f18f3bd1dabdf181a070b9fdbfe4a442622))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.8.4-ce.0
+  ([`832cb88`](https://github.com/python-gitlab/python-gitlab/commit/832cb88992cd7af4903f8b780e9475c03c0e6e56))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.9.0-ce.0
+  ([`3aef19c`](https://github.com/python-gitlab/python-gitlab/commit/3aef19c51713bdc7ca0a84752da3ca22329fd4c4))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.9.1-ce.0
+  ([`f6fd995`](https://github.com/python-gitlab/python-gitlab/commit/f6fd99530d70f2a7626602fd9132b628bb968eab))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.9.2-ce.0
+  ([`933ba52`](https://github.com/python-gitlab/python-gitlab/commit/933ba52475e5dae4cf7c569d8283e60eebd5b7b6))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.9.3-ce.0
+  ([`2ddf45f`](https://github.com/python-gitlab/python-gitlab/commit/2ddf45fed0b28e52d31153d9b1e95d0cae05e9f5))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.9.4-ce.0
+  ([`939f769`](https://github.com/python-gitlab/python-gitlab/commit/939f769e7410738da2e1c5d502caa765f362efdd))
+
+- **deps**: Update precommit hook alessandrojcm/commitlint-pre-commit-hook to v4
+  ([`505a8b8`](https://github.com/python-gitlab/python-gitlab/commit/505a8b8d7f16e609f0cde70be88a419235130f2f))
+
+- **deps**: Update wagoid/commitlint-github-action action to v3
+  ([`b3274cf`](https://github.com/python-gitlab/python-gitlab/commit/b3274cf93dfb8ae85e4a636a1ffbfa7c48f1c8f6))
+
+- **objects**: Make Project refreshable
+  ([`958a6aa`](https://github.com/python-gitlab/python-gitlab/commit/958a6aa83ead3fb6be6ec61bdd894ad78346e7bd))
+
+Helps getting the real state of the project from the server.
+
+- **objects**: Remove noisy deprecation warning for audit events
+  ([`2953642`](https://github.com/python-gitlab/python-gitlab/commit/29536423e3e8866eda7118527a49b120fefb4065))
+
+It's mostly an internal thing anyway and can be removed in 3.0.0
+
+- **tests**: Remove unused URL segment
+  ([`66f0b6c`](https://github.com/python-gitlab/python-gitlab/commit/66f0b6c23396b849f8653850b099e664daa05eb4))
+
+### Documentation
+
+- Add docs and examples for custom user agent
+  ([`a69a214`](https://github.com/python-gitlab/python-gitlab/commit/a69a214ef7f460cef7a7f44351c4861503f9902e))
+
+- Add information about the gitter community
+  ([`6ff67e7`](https://github.com/python-gitlab/python-gitlab/commit/6ff67e7327b851fa67be6ad3d82f88ff7cce0dc9))
+
+Add a section in the README.rst about the gitter community. The badge already exists and is useful
+  but very easy to miss.
+
+- Change travis-ci badge to githubactions
+  ([`2ba5ba2`](https://github.com/python-gitlab/python-gitlab/commit/2ba5ba244808049aad1ee3b42d1da258a9db9f61))
+
+- **api**: Add examples for resource state events
+  ([`4d00c12`](https://github.com/python-gitlab/python-gitlab/commit/4d00c12723d565dc0a83670f62e3f5102650d822))
+
+- **api**: Add release links API docs
+  ([`36d65f0`](https://github.com/python-gitlab/python-gitlab/commit/36d65f03db253d710938c2d827c1124c94a40506))
+
+### Features
+
+- Add an initial mypy test to tox.ini
+  ([`fdec039`](https://github.com/python-gitlab/python-gitlab/commit/fdec03976a17e0708459ba2fab22f54173295f71))
+
+Add an initial mypy test to test gitlab/base.py and gitlab/__init__.py
+
+- Add personal access token API
+  ([`2bb16fa`](https://github.com/python-gitlab/python-gitlab/commit/2bb16fac18a6a91847201c174f3bf1208338f6aa))
+
+See: https://docs.gitlab.com/ee/api/personal_access_tokens.html
+
+- Add project audit endpoint
+  ([`6660dbe`](https://github.com/python-gitlab/python-gitlab/commit/6660dbefeeffc2b39ddfed4928a59ed6da32ddf4))
+
+- Add ProjectPackageFile
+  ([`b9d469b`](https://github.com/python-gitlab/python-gitlab/commit/b9d469bc4e847ae0301be28a0c70019a7f6ab8b6))
+
+Add ProjectPackageFile and the ability to list project package package_files.
+
+Fixes #1372
+
+- Import from bitbucket server
+  ([`ff3013a`](https://github.com/python-gitlab/python-gitlab/commit/ff3013a2afeba12811cb3d860de4d0ea06f90545))
+
+I'd like to use this libary to automate importing Bitbucket Server repositories into GitLab. There
+  is a [GitLab API
+  endpoint](https://docs.gitlab.com/ee/api/import.html#import-repository-from-bitbucket-server) to
+  do this, but it is not exposed through this library.
+
+* Add an `import_bitbucket_server` method to the `ProjectManager`. This method calls this GitLab API
+  endpoint: https://docs.gitlab.com/ee/api/import.html#import-repository-from-bitbucket-server *
+  Modify `import_gitlab` method docstring for python3 compatibility * Add a skipped stub test for
+  the existing `import_github` method
+
+- Option to add a helper to lookup token
+  ([`8ecf559`](https://github.com/python-gitlab/python-gitlab/commit/8ecf55926f8e345960560e5c5dd6716199cfb0ec))
+
+- **api,cli**: Make user agent configurable
+  ([`4bb201b`](https://github.com/python-gitlab/python-gitlab/commit/4bb201b92ef0dcc14a7a9c83e5600ba5b118fc33))
+
+- **issues**: Add missing get verb to IssueManager
+  ([`f78ebe0`](https://github.com/python-gitlab/python-gitlab/commit/f78ebe065f73b29555c2dcf17b462bb1037a153e))
+
+- **objects**: Add Release Links API support
+  ([`28d7518`](https://github.com/python-gitlab/python-gitlab/commit/28d751811ffda45ff0b1c35e0599b655f3a5a68b))
+
+- **objects**: Add support for group audit events API
+  ([`2a0fbdf`](https://github.com/python-gitlab/python-gitlab/commit/2a0fbdf9fe98da6c436230be47b0ddb198c7eca9))
+
+- **objects**: Add support for resource state events API
+  ([`d4799c4`](https://github.com/python-gitlab/python-gitlab/commit/d4799c40bd12ed85d4bb834464fdb36c4dadcab6))
+
+- **projects**: Add project access token api
+  ([`1becef0`](https://github.com/python-gitlab/python-gitlab/commit/1becef0253804f119c8a4d0b8b1c53deb2f4d889))
+
+- **users**: Add follow/unfollow API
+  ([`e456869`](https://github.com/python-gitlab/python-gitlab/commit/e456869d98a1b7d07e6f878a0d6a9719c1b10fd4))
+
+### Refactoring
+
+- Move Gitlab and GitlabList to gitlab/client.py
+  ([`53a7645`](https://github.com/python-gitlab/python-gitlab/commit/53a764530cc3c6411034a3798f794545881d341e))
+
+Move the classes Gitlab and GitlabList from gitlab/__init__.py to the newly created gitlab/client.py
+  file.
+
+Update one test case that was depending on requests being defined in gitlab/__init__.py
+
+- **api**: Explicitly export classes for star imports
+  ([`f05c287`](https://github.com/python-gitlab/python-gitlab/commit/f05c287512a9253c7f7d308d3437240ac8257452))
+
+- **objects**: Move instance audit events where they belong
+  ([`48ba88f`](https://github.com/python-gitlab/python-gitlab/commit/48ba88ffb983207da398ea2170c867f87a8898e9))
+
+- **v4**: Split objects and managers per API resource
+  ([`a5a48ad`](https://github.com/python-gitlab/python-gitlab/commit/a5a48ad08577be70c6ca511d3b4803624e5c2043))
+
+### Testing
+
+- Don't add duplicate fixture
+  ([`5d94846`](https://github.com/python-gitlab/python-gitlab/commit/5d9484617e56b89ac5e17f8fc94c0b1eb46d4b89))
+
+Co-authored-by: Nejc Habjan <hab.nejc@gmail.com>
+
+- **api**: Add functional test for release links API
+  ([`ab2a1c8`](https://github.com/python-gitlab/python-gitlab/commit/ab2a1c816d83e9e308c0c9c7abf1503438b0b3be))
+
+- **api,cli**: Add tests for custom user agent
+  ([`c5a37e7`](https://github.com/python-gitlab/python-gitlab/commit/c5a37e7e37a62372c250dfc8c0799e847eecbc30))
+
+- **object**: Add test for __dir__ duplicates
+  ([`a8e591f`](https://github.com/python-gitlab/python-gitlab/commit/a8e591f742f777f8747213b783271004e5acc74d))
+
+- **objects**: Add tests for resource state events
+  ([`10225cf`](https://github.com/python-gitlab/python-gitlab/commit/10225cf26095efe82713136ddde3330e7afc6d10))
+
+- **objects**: Add unit test for instance audit events
+  ([`84e3247`](https://github.com/python-gitlab/python-gitlab/commit/84e3247d0cd3ddb1f3aa0ac91fb977c3e1e197b5))
+
+
+## v2.6.0 (2021-01-29)
+
+### Bug Fixes
+
+- Docs changed using the consts
+  ([`650b65c`](https://github.com/python-gitlab/python-gitlab/commit/650b65c389c686bcc9a9cef81b6ca2a509d8cad2))
+
+- Typo
+  ([`9baa905`](https://github.com/python-gitlab/python-gitlab/commit/9baa90535b5a8096600f9aec96e528f4d2ac7d74))
+
+- **api**: Add missing runner access_level param
+  ([`92669f2`](https://github.com/python-gitlab/python-gitlab/commit/92669f2ef2af3cac1c5f06f9299975060cc5e64a))
+
+- **api**: Use RetrieveMixin for ProjectLabelManager
+  ([`1a14395`](https://github.com/python-gitlab/python-gitlab/commit/1a143952119ce8e964cc7fcbfd73b8678ee2da74))
+
+Allows to get a single label from a project, which was missing before even though the GitLab API has
+  the ability to.
+
+- **base**: Really refresh object
+  ([`e1e0d8c`](https://github.com/python-gitlab/python-gitlab/commit/e1e0d8cbea1fed8aeb52b4d7cccd2e978faf2d3f))
+
+This fixes and error, where deleted attributes would not show up
+
+Fixes #1155
+
+- **cli**: Add missing args for project lists
+  ([`c73e237`](https://github.com/python-gitlab/python-gitlab/commit/c73e23747d24ffef3c1a2a4e5f4ae24252762a71))
+
+- **cli**: Write binary data to stdout buffer
+  ([`0733ec6`](https://github.com/python-gitlab/python-gitlab/commit/0733ec6cad5c11b470ce6bad5dc559018ff73b3c))
+
+### Chores
+
+- Added constants for search API
+  ([`8ef53d6`](https://github.com/python-gitlab/python-gitlab/commit/8ef53d6f6180440582d1cca305fd084c9eb70443))
+
+- Added docs for search scopes constants
+  ([`7565bf0`](https://github.com/python-gitlab/python-gitlab/commit/7565bf059b240c9fffaf6959ee168a12d0fedd77))
+
+- Allow overriding docker-compose env vars for tag
+  ([`27109ca`](https://github.com/python-gitlab/python-gitlab/commit/27109cad0d97114b187ce98ce77e4d7b0c7c3270))
+
+- Apply suggestions
+  ([`65ce026`](https://github.com/python-gitlab/python-gitlab/commit/65ce02675d9c9580860df91b41c3cf5e6bb8d318))
+
+- Move .env into docker-compose dir
+  ([`55cbd1c`](https://github.com/python-gitlab/python-gitlab/commit/55cbd1cbc28b93673f73818639614c61c18f07d1))
+
+- Offically support and test 3.9
+  ([`62dd07d`](https://github.com/python-gitlab/python-gitlab/commit/62dd07df98341f35c8629e8f0a987b35b70f7fe6))
+
+- Remove unnecessary random function
+  ([`d4ee0a6`](https://github.com/python-gitlab/python-gitlab/commit/d4ee0a6085d391ed54d715a5ed4b0082783ca8f3))
+
+- Simplified search scope constants
+  ([`16fc048`](https://github.com/python-gitlab/python-gitlab/commit/16fc0489b2fe24e0356e9092c9878210b7330a72))
+
+- Use helper fixtures for test directories
+  ([`40ec2f5`](https://github.com/python-gitlab/python-gitlab/commit/40ec2f528b885290fbb3e2d7ef0f5f8615219326))
+
+- **ci**: Add .readthedocs.yml
+  ([`0ad441e`](https://github.com/python-gitlab/python-gitlab/commit/0ad441eee5f2ac1b7c05455165e0085045c24b1d))
+
+- **ci**: Add coverage and docs jobs
+  ([`2de64cf`](https://github.com/python-gitlab/python-gitlab/commit/2de64cfa469c9d644a2950d3a4884f622ed9faf4))
+
+- **ci**: Add pytest PR annotations
+  ([`8f92230`](https://github.com/python-gitlab/python-gitlab/commit/8f9223041481976522af4c4f824ad45e66745f29))
+
+- **ci**: Fix copy/paste oopsie
+  ([`c6241e7`](https://github.com/python-gitlab/python-gitlab/commit/c6241e791357d3f90e478c456cc6d572b388e6d1))
+
+- **ci**: Fix typo in matrix
+  ([`5e1547a`](https://github.com/python-gitlab/python-gitlab/commit/5e1547a06709659c75d40a05ac924c51caffcccf))
+
+- **ci**: Force colors in pytest runs
+  ([`1502079`](https://github.com/python-gitlab/python-gitlab/commit/150207908a72869869d161ecb618db141e3a9348))
+
+- **ci**: Pin docker-compose install for tests
+  ([`1f7a2ab`](https://github.com/python-gitlab/python-gitlab/commit/1f7a2ab5bd620b06eb29146e502e46bd47432821))
+
+This ensures python-dotenv with expected behavior for .env processing
+
+- **ci**: Pin os version
+  ([`cfa27ac`](https://github.com/python-gitlab/python-gitlab/commit/cfa27ac6453f20e1d1f33973aa8cbfccff1d6635))
+
+- **ci**: Reduce renovate PR noise
+  ([`f4d7a55`](https://github.com/python-gitlab/python-gitlab/commit/f4d7a5503f3a77f6aa4d4e772c8feb3145044fec))
+
+- **ci**: Replace travis with Actions
+  ([`8bb73a3`](https://github.com/python-gitlab/python-gitlab/commit/8bb73a3440b79df93c43214c31332ad47ab286d8))
+
+- **cli**: Remove python2 code
+  ([`1030e0a`](https://github.com/python-gitlab/python-gitlab/commit/1030e0a7e13c4ec3fdc48b9010e9892833850db9))
+
+- **deps**: Pin dependencies
+  ([`14d8f77`](https://github.com/python-gitlab/python-gitlab/commit/14d8f77601a1ee4b36888d68f0102dd1838551f2))
+
+- **deps**: Pin dependency requests-toolbelt to ==0.9.1
+  ([`4d25f20`](https://github.com/python-gitlab/python-gitlab/commit/4d25f20e8f946ab58d1f0c2ef3a005cb58dc8b6c))
+
+- **deps**: Update dependency requests to v2.25.1
+  ([`9c2789e`](https://github.com/python-gitlab/python-gitlab/commit/9c2789e4a55822d7c50284adc89b9b6bfd936a72))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.3.3-ce.0
+  ([`667bf01`](https://github.com/python-gitlab/python-gitlab/commit/667bf01b6d3da218df6c4fbdd9c7b9282a2aaff9))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.3.4-ce.0
+  ([`e94c4c6`](https://github.com/python-gitlab/python-gitlab/commit/e94c4c67f21ecaa2862f861953c2d006923d3280))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.3.5-ce.0
+  ([`c88d870`](https://github.com/python-gitlab/python-gitlab/commit/c88d87092f39d11ecb4f52ab7cf49634a0f27e80))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.3.6-ce.0
+  ([`57b5782`](https://github.com/python-gitlab/python-gitlab/commit/57b5782219a86153cc3425632e232db3f3c237d7))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.4.3-ce.0
+  ([`bc17889`](https://github.com/python-gitlab/python-gitlab/commit/bc178898776d2d61477ff773248217adfac81f56))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.5.0-ce.0
+  ([`fc205cc`](https://github.com/python-gitlab/python-gitlab/commit/fc205cc593a13ec2ce5615293a9c04c262bd2085))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.5.1-ce.0
+  ([`348e860`](https://github.com/python-gitlab/python-gitlab/commit/348e860a9128a654eff7624039da2c792a1c9124))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.5.2-ce.0
+  ([`4a6831c`](https://github.com/python-gitlab/python-gitlab/commit/4a6831c6aa6eca8e976be70df58187515e43f6ce))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.5.3-ce.0
+  ([`d1b0b08`](https://github.com/python-gitlab/python-gitlab/commit/d1b0b08e4efdd7be2435833a28d12866fe098d44))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.5.4-ce.0
+  ([`265dbbd`](https://github.com/python-gitlab/python-gitlab/commit/265dbbdd37af88395574564aeb3fd0350288a18c))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.8.1-ce.0
+  ([`9854d6d`](https://github.com/python-gitlab/python-gitlab/commit/9854d6da84c192f765e0bc80d13bc4dae16caad6))
+
+- **deps**: Update python docker tag to v3.9
+  ([`1fc65e0`](https://github.com/python-gitlab/python-gitlab/commit/1fc65e072003a2d1ebc29d741e9cef1860b5ff78))
+
+- **docs**: Always edit the file directly on master
+  ([`35e43c5`](https://github.com/python-gitlab/python-gitlab/commit/35e43c54cd282f06dde0d24326641646fc3fa29e))
+
+There is no way to edit the raw commit
+
+- **test**: Remove hacking dependencies
+  ([`9384493`](https://github.com/python-gitlab/python-gitlab/commit/9384493942a4a421aced4bccc7c7291ff30af886))
+
+### Documentation
+
+- Add Project Merge Request approval rule documentation
+  ([`449fc26`](https://github.com/python-gitlab/python-gitlab/commit/449fc26ffa98ef5703d019154f37a4959816f607))
+
+- Clean up grammar and formatting in documentation
+  ([`aff9bc7`](https://github.com/python-gitlab/python-gitlab/commit/aff9bc737d90e1a6e91ab8efa40a6756c7ce5cba))
+
+- **cli**: Add auto-generated CLI reference
+  ([`6c21fc8`](https://github.com/python-gitlab/python-gitlab/commit/6c21fc83d3d6173bffb60e686ec579f875f8bebe))
+
+- **cli**: Add example for job artifacts download
+  ([`375b29d`](https://github.com/python-gitlab/python-gitlab/commit/375b29d3ab393f7b3fa734c5320736cdcba5df8a))
+
+- **cli**: Use inline anonymous references for external links
+  ([`f2cf467`](https://github.com/python-gitlab/python-gitlab/commit/f2cf467443d1c8a1a24a8ebf0ec1ae0638871336))
+
+There doesn't seem to be an obvious way to use an alias for identical text labels that link to
+  different targets. With inline links we can work around this shortcoming. Until we know better.
+
+- **cli-usage**: Fixed term
+  ([`d282a99`](https://github.com/python-gitlab/python-gitlab/commit/d282a99e29abf390c926dcc50984ac5523d39127))
+
+- **groups**: Add example for creating subgroups
+  ([`92eb4e3`](https://github.com/python-gitlab/python-gitlab/commit/92eb4e3ca0ccd83dba2067ccc4ce206fd17be020))
+
+- **issues**: Add admin, project owner hint
+  ([`609c03b`](https://github.com/python-gitlab/python-gitlab/commit/609c03b7139db8af5524ebeb741fd5b003e17038))
+
+Closes #1101
+
+- **projects**: Correct fork docs
+  ([`54921db`](https://github.com/python-gitlab/python-gitlab/commit/54921dbcf117f6b939e0c467738399be0d661a00))
+
+Closes #1126
+
+- **readme**: Also add hint to delete gitlab-runner-test
+  ([`8894f2d`](https://github.com/python-gitlab/python-gitlab/commit/8894f2da81d885c1e788a3b21686212ad91d5bf2))
+
+Otherwise the whole testsuite will refuse to run
+
+- **readme**: Update supported Python versions
+  ([`20b1e79`](https://github.com/python-gitlab/python-gitlab/commit/20b1e791c7a78633682b2d9f7ace8eb0636f2424))
+
+### Features
+
+- Add MINIMAL_ACCESS constant
+  ([`49eb3ca`](https://github.com/python-gitlab/python-gitlab/commit/49eb3ca79172905bf49bab1486ecb91c593ea1d7))
+
+A "minimal access" access level was
+  [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/220203) in GitLab 13.5.
+
+- Added support for pipeline bridges
+  ([`05cbdc2`](https://github.com/python-gitlab/python-gitlab/commit/05cbdc224007e9dda10fc2f6f7d63c82cf36dec0))
+
+- Adds support for project merge request approval rules
+  ([#1199](https://github.com/python-gitlab/python-gitlab/pull/1199),
+  [`c6fbf39`](https://github.com/python-gitlab/python-gitlab/commit/c6fbf399ec5cbc92f995a5d61342f295be68bd79))
+
+- Support multipart uploads
+  ([`2fa3004`](https://github.com/python-gitlab/python-gitlab/commit/2fa3004d9e34cc4b77fbd6bd89a15957898e1363))
+
+- Unit tests added
+  ([`f37ebf5`](https://github.com/python-gitlab/python-gitlab/commit/f37ebf5fd792c8e8a973443a1df386fa77d1248f))
+
+- **api**: Add support for user identity provider deletion
+  ([`e78e121`](https://github.com/python-gitlab/python-gitlab/commit/e78e121575deb7b5ce490b2293caa290860fc3e9))
+
+- **api**: Added wip filter param for merge requests
+  ([`d6078f8`](https://github.com/python-gitlab/python-gitlab/commit/d6078f808bf19ef16cfebfaeabb09fbf70bfb4c7))
+
+- **api**: Added wip filter param for merge requests
+  ([`aa6e80d`](https://github.com/python-gitlab/python-gitlab/commit/aa6e80d58d765102892fadb89951ce29d08e1dab))
+
+- **tests**: Test label getter
+  ([`a41af90`](https://github.com/python-gitlab/python-gitlab/commit/a41af902675a07cd4772bb122c152547d6d570f7))
+
+### Refactoring
+
+- **tests**: Split functional tests
+  ([`61e43eb`](https://github.com/python-gitlab/python-gitlab/commit/61e43eb186925feede073c7065e5ae868ffbb4ec))
+
+### Testing
+
+- Add test_project_merge_request_approvals.py
+  ([`9f6335f`](https://github.com/python-gitlab/python-gitlab/commit/9f6335f7b79f52927d5c5734e47f4b8d35cd6c4a))
+
+- Add unit tests for badges API
+  ([`2720b73`](https://github.com/python-gitlab/python-gitlab/commit/2720b7385a3686d3adaa09a3584d165bd7679367))
+
+- Add unit tests for resource label events API
+  ([`e9a211c`](https://github.com/python-gitlab/python-gitlab/commit/e9a211ca8080e07727d0217e1cdc2851b13a85b7))
+
+- Ignore failing test for now
+  ([`4b4e253`](https://github.com/python-gitlab/python-gitlab/commit/4b4e25399f35e204320ac9f4e333b8cf7b262595))
+
+- **cli**: Add test for job artifacts download
+  ([`f4e7950`](https://github.com/python-gitlab/python-gitlab/commit/f4e79501f1be1394873042dd65beda49e869afb8))
+
+- **env**: Replace custom scripts with pytest and docker-compose
+  ([`79489c7`](https://github.com/python-gitlab/python-gitlab/commit/79489c775141c4ddd1f7aecae90dae8061d541fe))
+
+
+## v2.5.0 (2020-09-01)
+
+### Bug Fixes
+
+- Implement Gitlab's behavior change for owned=True
+  ([`9977799`](https://github.com/python-gitlab/python-gitlab/commit/99777991e0b9d5a39976d08554dea8bb7e514019))
+
+- Tests fail when using REUSE_CONTAINER option
+  ([`0078f89`](https://github.com/python-gitlab/python-gitlab/commit/0078f8993c38df4f02da9aaa3f7616d1c8b97095))
+
+Fixes #1146
+
+- Wrong reconfirmation parameter when updating user's email
+  ([`b5c267e`](https://github.com/python-gitlab/python-gitlab/commit/b5c267e110b2d7128da4f91c62689456d5ce275f))
+
+Since version 10.3 (and later), param to not send (re)confirmation when updating an user is
+  `skip_reconfirmation` (and not `skip_confirmation`).
+
+See:
+
+* https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/15175?tab= *
+  https://docs.gitlab.com/11.11/ee/api/users.html#user-modification *
+  https://docs.gitlab.com/ee/api/users.html#user-modification
+
+### Chores
+
+- Bump python-gitlab to 2.5.0
+  ([`56fef01`](https://github.com/python-gitlab/python-gitlab/commit/56fef0180431f442ada5ce62352e4e813288257d))
+
+- Make latest black happy with existing code
+  ([`6961479`](https://github.com/python-gitlab/python-gitlab/commit/696147922552a8e6ddda3a5b852ee2de6b983e37))
+
+- Make latest black happy with existing code
+  ([`4039c8c`](https://github.com/python-gitlab/python-gitlab/commit/4039c8cfc6c7783270f0da1e235ef5d70b420ba9))
+
+- Make latest black happy with existing code
+  ([`d299753`](https://github.com/python-gitlab/python-gitlab/commit/d2997530bc3355048143bc29580ef32fc21dac3d))
+
+- Remove remnants of python2 imports
+  ([`402566a`](https://github.com/python-gitlab/python-gitlab/commit/402566a665dfdf0862f15a7e59e4d804d1301c77))
+
+- Remove unnecessary import
+  ([`f337b7a`](https://github.com/python-gitlab/python-gitlab/commit/f337b7ac43e49f9d3610235749b1e2a21731352d))
+
+- Run unittest2pytest on all unit tests
+  ([`11383e7`](https://github.com/python-gitlab/python-gitlab/commit/11383e70f74c70e6fe8a56f18b5b170db982f402))
+
+- Update tools dir for latest black version
+  ([`c2806d8`](https://github.com/python-gitlab/python-gitlab/commit/c2806d8c0454a83dfdafd1bdbf7e10bb28d205e0))
+
+- Update tools dir for latest black version
+  ([`f245ffb`](https://github.com/python-gitlab/python-gitlab/commit/f245ffbfad6f1d1f66d386a4b00b3a6ff3e74daa))
+
+- **ci**: Pin gitlab-ce version for renovate
+  ([`cb79fb7`](https://github.com/python-gitlab/python-gitlab/commit/cb79fb72e899e65a1ad77ccd508f1a1baca30309))
+
+- **ci**: Use fixed black version
+  ([`9565684`](https://github.com/python-gitlab/python-gitlab/commit/9565684c86cb018fb22ee0b29345d2cd130f3fd7))
+
+- **deps**: Update gitlab/gitlab-ce docker tag to v13.3.2-ce.0
+  ([`9fd778b`](https://github.com/python-gitlab/python-gitlab/commit/9fd778b4a7e92a7405ac2f05c855bafbc51dc6a8))
+
+- **deps**: Update python docker tag to v3.8
+  ([`a8070f2`](https://github.com/python-gitlab/python-gitlab/commit/a8070f2d9a996e57104f29539069273774cf5493))
+
+- **env**: Add pre-commit and commit-msg hooks
+  ([`82070b2`](https://github.com/python-gitlab/python-gitlab/commit/82070b2d2ed99189aebb1d595430ad5567306c4c))
+
+- **test**: Use pathlib for paths
+  ([`5a56b6b`](https://github.com/python-gitlab/python-gitlab/commit/5a56b6b55f761940f80491eddcdcf17d37215cfd))
+
+### Documentation
+
+- Additional project file delete example
+  ([`9e94b75`](https://github.com/python-gitlab/python-gitlab/commit/9e94b7511de821619e8bcf66a3ae1f187f15d594))
+
+Showing how to delete without having to pull the file
+
+- **api**: Add example for latest pipeline job artifacts
+  ([`d20f022`](https://github.com/python-gitlab/python-gitlab/commit/d20f022a8fe29a6086d30aa7616aa1dac3e1bb17))
+
+- **cli**: Add examples for group-project list
+  ([`af86dcd`](https://github.com/python-gitlab/python-gitlab/commit/af86dcdd28ee1b16d590af31672c838597e3f3ec))
+
+- **packages**: Add examples for Packages API and cli usage
+  ([`a47dfcd`](https://github.com/python-gitlab/python-gitlab/commit/a47dfcd9ded3a0467e83396f21e6dcfa232dfdd7))
+
+- **variables**: Add docs for instance-level variables
+  ([`ad4b87c`](https://github.com/python-gitlab/python-gitlab/commit/ad4b87cb3d6802deea971e6574ae9afe4f352e31))
+
+### Features
+
+- Add share/unshare group with group
+  ([`7c6e541`](https://github.com/python-gitlab/python-gitlab/commit/7c6e541dc2642740a6ec2d7ed7921aca41446b37))
+
+- Add support to resource milestone events
+  ([`88f8cc7`](https://github.com/python-gitlab/python-gitlab/commit/88f8cc78f97156d5888a9600bdb8721720563120))
+
+Fixes #1154
+
+- **api**: Add endpoint for latest ref artifacts
+  ([`b7a07fc`](https://github.com/python-gitlab/python-gitlab/commit/b7a07fca775b278b1de7d5cb36c8421b7d9bebb7))
+
+- **api**: Add support for instance variables
+  ([`4492fc4`](https://github.com/python-gitlab/python-gitlab/commit/4492fc42c9f6e0031dd3f3c6c99e4c58d4f472ff))
+
+- **api**: Add support for Packages API
+  ([`71495d1`](https://github.com/python-gitlab/python-gitlab/commit/71495d127d30d2f4c00285485adae5454a590584))
+
+### Refactoring
+
+- Rewrite unit tests for objects with responses
+  ([`204782a`](https://github.com/python-gitlab/python-gitlab/commit/204782a117f77f367dee87aa2c70822587829147))
+
+- Split unit tests by GitLab API resources
+  ([`76b2cad`](https://github.com/python-gitlab/python-gitlab/commit/76b2cadf1418e4ea2ac420ebba5a4b4f16fbd4c7))
+
+- Turn objects module into a package
+  ([`da8af6f`](https://github.com/python-gitlab/python-gitlab/commit/da8af6f6be6886dca4f96390632cf3b91891954e))
+
+### Testing
+
+- Add unit tests for resource milestone events API
+  ([`1317f4b`](https://github.com/python-gitlab/python-gitlab/commit/1317f4b62afefcb2504472d5b5d8e24f39b0d86f))
+
+Fixes #1154
+
+- **api**: Add tests for variables API
+  ([`66d108d`](https://github.com/python-gitlab/python-gitlab/commit/66d108de9665055921123476426fb6716c602496))
+
+- **packages**: Add tests for Packages API
+  ([`7ea178b`](https://github.com/python-gitlab/python-gitlab/commit/7ea178bad398c8c2851a4584f4dca5b8adc89d29))
+
+
+## v2.4.0 (2020-07-09)
+
+### Bug Fixes
+
+- Add masked parameter for variables command
+  ([`b6339bf`](https://github.com/python-gitlab/python-gitlab/commit/b6339bf85f3ae11d31bf03c4132f6e7b7c343900))
+
+- Do not check if kwargs is none
+  ([`a349b90`](https://github.com/python-gitlab/python-gitlab/commit/a349b90ea6016ec8fbe91583f2bbd9832b41a368))
+
+Co-authored-by: Traian Nedelea <tron1point0@pm.me>
+
+- Make query kwargs consistent between call in init and next
+  ([`72ffa01`](https://github.com/python-gitlab/python-gitlab/commit/72ffa0164edc44a503364f9b7e25c5b399f648c3))
+
+- Pass kwargs to subsequent queries in gitlab list
+  ([`1d011ac`](https://github.com/python-gitlab/python-gitlab/commit/1d011ac72aeb18b5f31d10e42ffb49cf703c3e3a))
+
+- **merge**: Parse arguments as query_data
+  ([`878098b`](https://github.com/python-gitlab/python-gitlab/commit/878098b74e216b4359e0ce012ff5cd6973043a0a))
+
+### Chores
+
+- Bump version to 2.4.0
+  ([`1606310`](https://github.com/python-gitlab/python-gitlab/commit/1606310a880f8a8a2a370db27511b57732caf178))
+
+### Documentation
+
+- **pipelines**: Simplify download
+  ([`9a068e0`](https://github.com/python-gitlab/python-gitlab/commit/9a068e00eba364eb121a2d7d4c839e2f4c7371c8))
+
+This uses a context instead of inventing your own stream handler which makes the code simpler and
+  should be fine for most use cases.
+
+Signed-off-by: Paul Spooren <mail@aparcar.org>
+
+### Features
+
+- Added NO_ACCESS const
+  ([`dab4d0a`](https://github.com/python-gitlab/python-gitlab/commit/dab4d0a1deec6d7158c0e79b9eef20d53c0106f0))
+
+This constant is useful for cases where no access is granted, e.g. when creating a protected branch.
+
+The `NO_ACCESS` const corresponds to the definition in
+  https://docs.gitlab.com/ee/api/protected_branches.html
+
+
+## v2.3.1 (2020-06-09)
+
+### Bug Fixes
+
+- Disable default keyset pagination
+  ([`e71fe16`](https://github.com/python-gitlab/python-gitlab/commit/e71fe16b47835aa4db2834e98c7ffc6bdec36723))
+
+Instead we set pagination to offset on the other paths
+
+### Chores
+
+- Bump version to 2.3.1
+  ([`870e7ea`](https://github.com/python-gitlab/python-gitlab/commit/870e7ea12ee424eb2454dd7d4b7906f89fbfea64))
+
+
+## v2.3.0 (2020-06-08)
+
+### Bug Fixes
+
+- Use keyset pagination by default for /projects > 50000
+  ([`f86ef3b`](https://github.com/python-gitlab/python-gitlab/commit/f86ef3bbdb5bffa1348a802e62b281d3f31d33ad))
+
+Workaround for https://gitlab.com/gitlab-org/gitlab/-/issues/218504. Remove this in 13.1
+
+- **config**: Fix duplicate code
+  ([`ee2df6f`](https://github.com/python-gitlab/python-gitlab/commit/ee2df6f1757658cae20cc1d9dd75be599cf19997))
+
+Fixes #1094
+
+- **project**: Add missing project parameters
+  ([`ad8c67d`](https://github.com/python-gitlab/python-gitlab/commit/ad8c67d65572a9f9207433e177834cc66f8e48b3))
+
+### Chores
+
+- Bring commit signatures up to date with 12.10
+  ([`dc382fe`](https://github.com/python-gitlab/python-gitlab/commit/dc382fe3443a797e016f8c5f6eac68b7b69305ab))
+
+- Bump to 2.3.0
+  ([`01ff865`](https://github.com/python-gitlab/python-gitlab/commit/01ff8658532e7a7d3b53ba825c7ee311f7feb1ab))
+
+- Correctly render rst
+  ([`f674bf2`](https://github.com/python-gitlab/python-gitlab/commit/f674bf239e6ced4f420bee0a642053f63dace28b))
+
+- Fix typo in docstring
+  ([`c20f5f1`](https://github.com/python-gitlab/python-gitlab/commit/c20f5f15de84d1b1bbb12c18caf1927dcfd6f393))
+
+- Remove old builds-email service
+  ([`c60e2df`](https://github.com/python-gitlab/python-gitlab/commit/c60e2df50773535f5cfdbbb974713f28828fd827))
+
+- Use pytest for unit tests and coverage
+  ([`9787a40`](https://github.com/python-gitlab/python-gitlab/commit/9787a407b700f18dadfb4153b3ba1375a615b73c))
+
+- **ci**: Add codecov integration to Travis
+  ([`e230568`](https://github.com/python-gitlab/python-gitlab/commit/e2305685dea2d99ca389f79dc40e40b8d3a1fee0))
+
+- **services**: Update available service attributes
+  ([`7afc357`](https://github.com/python-gitlab/python-gitlab/commit/7afc3570c02c5421df76e097ce33d1021820a3d6))
+
+- **test**: Remove outdated token test
+  ([`e6c9fe9`](https://github.com/python-gitlab/python-gitlab/commit/e6c9fe920df43ae2ab13f26310213e8e4db6b415))
+
+### Continuous Integration
+
+- Add a test for creating and triggering pipeline schedule
+  ([`9f04560`](https://github.com/python-gitlab/python-gitlab/commit/9f04560e59f372f80ac199aeee16378d8f80610c))
+
+- Lint fixes
+  ([`930122b`](https://github.com/python-gitlab/python-gitlab/commit/930122b1848b3d42af1cf8567a065829ec0eb44f))
+
+### Documentation
+
+- Update authors
+  ([`ac0c84d`](https://github.com/python-gitlab/python-gitlab/commit/ac0c84de02a237db350d3b21fe74d0c24d85a94e))
+
+- **readme**: Add codecov badge for master
+  ([`e21b2c5`](https://github.com/python-gitlab/python-gitlab/commit/e21b2c5c6a600c60437a41f231fea2adcfd89fbd))
+
+- **readme**: Update test docs
+  ([`6e2b1ec`](https://github.com/python-gitlab/python-gitlab/commit/6e2b1ec947a6e352b412fd4e1142006621dd76a4))
+
+- **remote_mirrors**: Fix create command
+  ([`bab91fe`](https://github.com/python-gitlab/python-gitlab/commit/bab91fe86fc8d23464027b1c3ab30619e520235e))
+
+- **remote_mirrors**: Fix create command
+  ([`1bb4e42`](https://github.com/python-gitlab/python-gitlab/commit/1bb4e42858696c9ac8cbfc0f89fa703921b969f3))
+
+### Features
+
+- Add group runners api
+  ([`4943991`](https://github.com/python-gitlab/python-gitlab/commit/49439916ab58b3481308df5800f9ffba8f5a8ffd))
+
+- Add play command to project pipeline schedules
+  ([`07b9988`](https://github.com/python-gitlab/python-gitlab/commit/07b99881dfa6efa9665245647460e99846ccd341))
+
+fix: remove version from setup
+
+feat: add pipeline schedule play error exception
+
+docs: add documentation for pipeline schedule play
+
+- Allow an environment variable to specify config location
+  ([`401e702`](https://github.com/python-gitlab/python-gitlab/commit/401e702a9ff14bf4cc33b3ed3acf16f3c60c6945))
+
+It can be useful (especially in scripts) to specify a configuration location via an environment
+  variable. If the "PYTHON_GITLAB_CFG" environment variable is defined, treat its value as the path
+  to a configuration file and include it in the set of default configuration locations.
+
+- **api**: Added support in the GroupManager to upload Group avatars
+  ([`28eb7ea`](https://github.com/python-gitlab/python-gitlab/commit/28eb7eab8fbe3750fb56e85967e8179b7025f441))
+
+- **services**: Add project service list API
+  ([`fc52221`](https://github.com/python-gitlab/python-gitlab/commit/fc5222188ad096932fa89bb53f03f7118926898a))
+
+Can be used to list available services It was introduced in GitLab 12.7
+
+- **types**: Add __dir__ to RESTObject to expose attributes
+  ([`cad134c`](https://github.com/python-gitlab/python-gitlab/commit/cad134c078573c009af18160652182e39ab5b114))
+
+### Testing
+
+- Disable test until Gitlab 13.1
+  ([`63ae77a`](https://github.com/python-gitlab/python-gitlab/commit/63ae77ac1d963e2c45bbed7948d18313caf2c016))
+
+- **cli**: Convert shell tests to pytest test cases
+  ([`c4ab4f5`](https://github.com/python-gitlab/python-gitlab/commit/c4ab4f57e23eed06faeac8d4fa9ffb9ce5d47e48))
+
+- **runners**: Add all runners unit tests
+  ([`127fa5a`](https://github.com/python-gitlab/python-gitlab/commit/127fa5a2134aee82958ce05357d60513569c3659))
+
+
+## v2.2.0 (2020-04-07)
+
+### Bug Fixes
+
+- Add missing import_project param
+  ([`9b16614`](https://github.com/python-gitlab/python-gitlab/commit/9b16614ba6444b212b3021a741b9c184ac206af1))
+
+- **types**: Do not split single value string in ListAttribute
+  ([`a26e585`](https://github.com/python-gitlab/python-gitlab/commit/a26e58585b3d82cf1a3e60a3b7b3bfd7f51d77e5))
+
+### Chores
+
+- Bump to 2.2.0
+  ([`22d4b46`](https://github.com/python-gitlab/python-gitlab/commit/22d4b465c3217536cb444dafe5c25e9aaa3aa7be))
+
+- Clean up for black and flake8
+  ([`4fede5d`](https://github.com/python-gitlab/python-gitlab/commit/4fede5d692fdd4477a37670b7b35268f5d1c4bf0))
+
+- Fix typo in allow_failures
+  ([`265bbdd`](https://github.com/python-gitlab/python-gitlab/commit/265bbddacc25d709a8f13807ed04cae393d9802d))
+
+- Flatten test_import_github
+  ([`b8ea96c`](https://github.com/python-gitlab/python-gitlab/commit/b8ea96cc20519b751631b27941d60c486aa4188c))
+
+- Improve and document testing against different images
+  ([`98d3f77`](https://github.com/python-gitlab/python-gitlab/commit/98d3f770c4cc7e15493380e1a2201c63f0a332a2))
+
+- Move test_import_github into TestProjectImport
+  ([`a881fb7`](https://github.com/python-gitlab/python-gitlab/commit/a881fb71eebf744bcbe232869f622ea8a3ac975f))
+
+- Pass environment variables in tox
+  ([`e06d33c`](https://github.com/python-gitlab/python-gitlab/commit/e06d33c1bcfa71e0c7b3e478d16b3a0e28e05a23))
+
+- Remove references to python2 in test env
+  ([`6e80723`](https://github.com/python-gitlab/python-gitlab/commit/6e80723e5fa00e8b870ec25d1cb2484d4b5816ca))
+
+- Rename ExportMixin to DownloadMixin
+  ([`847da60`](https://github.com/python-gitlab/python-gitlab/commit/847da6063b4c63c8133e5e5b5b45e5b4f004bdc4))
+
+- Use raise..from for chained exceptions
+  ([#939](https://github.com/python-gitlab/python-gitlab/pull/939),
+  [`79fef26`](https://github.com/python-gitlab/python-gitlab/commit/79fef262c3e05ff626981c891d9377abb1e18533))
+
+- **group**: Update group_manager attributes
+  ([#1062](https://github.com/python-gitlab/python-gitlab/pull/1062),
+  [`fa34f5e`](https://github.com/python-gitlab/python-gitlab/commit/fa34f5e20ecbd3f5d868df2fa9e399ac6559c5d5))
+
+* chore(group): update group_manager attributes
+
+Co-Authored-By: Nejc Habjan <hab.nejc@gmail.com>
+
+- **mixins**: Factor out export download into ExportMixin
+  ([`6ce5d1f`](https://github.com/python-gitlab/python-gitlab/commit/6ce5d1f14060a403f05993d77bf37720c25534ba))
+
+### Documentation
+
+- Add docs for Group Import/Export API
+  ([`8c3d744`](https://github.com/python-gitlab/python-gitlab/commit/8c3d744ec6393ad536b565c94f120b3e26b6f3e8))
+
+- Fix comment of prev_page()
+  ([`b066b41`](https://github.com/python-gitlab/python-gitlab/commit/b066b41314f55fbdc4ee6868d1e0aba1e5620a48))
+
+Co-Authored-By: Nejc Habjan <hab.nejc@gmail.com>
+
+- Fix comment of prev_page()
+  ([`ac6b2da`](https://github.com/python-gitlab/python-gitlab/commit/ac6b2daf8048f4f6dea14bbf142b8f3a00726443))
+
+Co-Authored-By: Nejc Habjan <hab.nejc@gmail.com>
+
+- Fix comment of prev_page()
+  ([`7993c93`](https://github.com/python-gitlab/python-gitlab/commit/7993c935f62e67905af558dd06394764e708cafe))
+
+### Features
+
+- Add create from template args to ProjectManager
+  ([`f493b73`](https://github.com/python-gitlab/python-gitlab/commit/f493b73e1fbd3c3f1a187fed2de26030f00a89c9))
+
+This commit adds the v4 Create project attributes necessary to create a project from a project,
+  instance, or group level template as documented in
+  https://docs.gitlab.com/ee/api/projects.html#create-project
+
+- Add support for commit GPG signature API
+  ([`da7a809`](https://github.com/python-gitlab/python-gitlab/commit/da7a809772233be27fa8e563925dd2e44e1ce058))
+
+- **api**: Add support for Gitlab Deploy Token API
+  ([`01de524`](https://github.com/python-gitlab/python-gitlab/commit/01de524ce39a67b549b3157bf4de827dd0568d6b))
+
+- **api**: Add support for Group Import/Export API
+  ([#1037](https://github.com/python-gitlab/python-gitlab/pull/1037),
+  [`6cb9d92`](https://github.com/python-gitlab/python-gitlab/commit/6cb9d9238ea3cc73689d6b71e991f2ec233ee8e6))
+
+- **api**: Add support for remote mirrors API
+  ([#1056](https://github.com/python-gitlab/python-gitlab/pull/1056),
+  [`4cfaa2f`](https://github.com/python-gitlab/python-gitlab/commit/4cfaa2fd44b64459f6fc268a91d4469284c0e768))
+
+### Testing
+
+- Add unit tests for Project Export
+  ([`600dc86`](https://github.com/python-gitlab/python-gitlab/commit/600dc86f34b6728b37a98b44e6aba73044bf3191))
+
+- Add unit tests for Project Import
+  ([`f7aad5f`](https://github.com/python-gitlab/python-gitlab/commit/f7aad5f78c49ad1a4e05a393bcf236b7bbad2f2a))
+
+- Create separate module for commit tests
+  ([`8c03771`](https://github.com/python-gitlab/python-gitlab/commit/8c037712a53c1c54e46298fbb93441d9b7a7144a))
+
+- Move mocks to top of module
+  ([`0bff713`](https://github.com/python-gitlab/python-gitlab/commit/0bff71353937a451b1092469330034062d24ff71))
+
+- Prepare base project test class for more tests
+  ([`915587f`](https://github.com/python-gitlab/python-gitlab/commit/915587f72de85b45880a2f1d50bdae1a61eb2638))
+
+- **api**: Add tests for group export/import API
+  ([`e7b2d6c`](https://github.com/python-gitlab/python-gitlab/commit/e7b2d6c873f0bfd502d06c9bd239cedc465e51c5))
+
+- **types**: Reproduce get_for_api splitting strings
+  ([#1057](https://github.com/python-gitlab/python-gitlab/pull/1057),
+  [`babd298`](https://github.com/python-gitlab/python-gitlab/commit/babd298eca0586dce134d65586bf50410aacd035))
+
+
+## v2.1.2 (2020-03-09)
+
+### Chores
+
+- Bump version to 2.1.2
+  ([`ad7e2bf`](https://github.com/python-gitlab/python-gitlab/commit/ad7e2bf7472668ffdcc85eec30db4139b92595a6))
+
+
+## v2.1.1 (2020-03-09)
+
+### Bug Fixes
+
+- **docs**: Additional project statistics example
+  ([`5ae5a06`](https://github.com/python-gitlab/python-gitlab/commit/5ae5a0627f85abba23cda586483630cefa7cf36c))
+
+### Chores
+
+- Bump version to 2.1.1
+  ([`6c5458a`](https://github.com/python-gitlab/python-gitlab/commit/6c5458a3bfc3208ad2d7cc40e1747f7715abe449))
+
+- **user**: Update user attributes to 12.8
+  ([`666f880`](https://github.com/python-gitlab/python-gitlab/commit/666f8806eb6b3455ea5531b08cdfc022916616f0))
+
+
+## v2.1.0 (2020-03-08)
+
+### Bug Fixes
+
+- Do not require empty data dict for create()
+  ([`99d959f`](https://github.com/python-gitlab/python-gitlab/commit/99d959f74d06cca8df3f2d2b3a4709faba7799cb))
+
+- Remove null values from features POST data, because it fails
+  ([`1ec1816`](https://github.com/python-gitlab/python-gitlab/commit/1ec1816d7c76ae079ad3b3e3b7a1bae70e0dd95b))
+
+- Remove trailing slashes from base URL
+  ([#913](https://github.com/python-gitlab/python-gitlab/pull/913),
+  [`2e396e4`](https://github.com/python-gitlab/python-gitlab/commit/2e396e4a84690c2ea2ea7035148b1a6038c03301))
+
+- Return response with commit data
+  ([`b77b945`](https://github.com/python-gitlab/python-gitlab/commit/b77b945c7e0000fad4c422a5331c7e905e619a33))
+
+- **docs**: Fix typo in user memberships example
+  ([`33889bc`](https://github.com/python-gitlab/python-gitlab/commit/33889bcbedb4aa421ea5bf83c13abe3168256c62))
+
+- **docs**: Update to new set approvers call for # of approvers
+  ([`8e0c526`](https://github.com/python-gitlab/python-gitlab/commit/8e0c52620af47a9e2247eeb7dcc7a2e677822ff4))
+
+to set the # of approvers for an MR you need to use the same function as for setting the approvers
+  id.
+
+- **docs and tests**: Update docs and tests for set_approvers
+  ([`2cf12c7`](https://github.com/python-gitlab/python-gitlab/commit/2cf12c7973e139c4932da1f31c33bb7658b132f7))
+
+Updated the docs with the new set_approvers arguments, and updated tests with the arg as well.
+
+- **objects**: Add default name data and use http post
+  ([`70c0cfb`](https://github.com/python-gitlab/python-gitlab/commit/70c0cfb686177bc17b796bf4d7eea8b784cf9651))
+
+Updating approvers new api needs a POST call. Also It needs a name of the new rule, defaulting this
+  to 'name'.
+
+- **objects**: Update set_approvers function call
+  ([`65ecadc`](https://github.com/python-gitlab/python-gitlab/commit/65ecadcfc724a7086e5f84dbf1ecc9f7a02e5ed8))
+
+Added a miss paramter update to the set_approvers function
+
+- **objects**: Update to new gitlab api for path, and args
+  ([`e512cdd`](https://github.com/python-gitlab/python-gitlab/commit/e512cddd30f3047230e8eedb79d98dc06e93a77b))
+
+Updated the gitlab path for set_approvers to approvers_rules, added default arg for rule type, and
+  added arg for # of approvals required.
+
+- **projects**: Correct copy-paste error
+  ([`adc9101`](https://github.com/python-gitlab/python-gitlab/commit/adc91011e46dfce909b7798b1257819ec09d01bd))
+
+### Chores
+
+- Bump version to 2.1.0
+  ([`47cb58c`](https://github.com/python-gitlab/python-gitlab/commit/47cb58c24af48c77c372210f9e791edd2c2c98b0))
+
+- Ensure developers use same gitlab image as Travis
+  ([`fab17fc`](https://github.com/python-gitlab/python-gitlab/commit/fab17fcd6258b8c3aa3ccf6c00ab7b048b6beeab))
+
+- Fix broken requests links
+  ([`b392c21`](https://github.com/python-gitlab/python-gitlab/commit/b392c21c669ae545a6a7492044479a401c0bcfb3))
+
+Another case of the double slash rewrite.
+
+### Code Style
+
+- Fix black violations
+  ([`ad3e833`](https://github.com/python-gitlab/python-gitlab/commit/ad3e833671c49db194c86e23981215b13b96bb1d))
+
+### Documentation
+
+- Add reference for REQUESTS_CA_BUNDLE
+  ([`37e8d5d`](https://github.com/python-gitlab/python-gitlab/commit/37e8d5d2f0c07c797e347a7bc1441882fe118ecd))
+
+- **pagination**: Clear up pagination docs
+  ([`1609824`](https://github.com/python-gitlab/python-gitlab/commit/16098244ad7c19867495cf4f0fda0c83fe54cd2b))
+
+Co-Authored-By: Mitar <mitar.git@tnode.com>
+
+### Features
+
+- Add capability to control GitLab features per project or group
+  ([`7f192b4`](https://github.com/python-gitlab/python-gitlab/commit/7f192b4f8734e29a63f1c79be322c25d45cfe23f))
+
+- Add support for commit revert API
+  ([#991](https://github.com/python-gitlab/python-gitlab/pull/991),
+  [`5298964`](https://github.com/python-gitlab/python-gitlab/commit/5298964ee7db8a610f23de2d69aad8467727ca97))
+
+- Add support for user memberships API
+  ([#1009](https://github.com/python-gitlab/python-gitlab/pull/1009),
+  [`c313c2b`](https://github.com/python-gitlab/python-gitlab/commit/c313c2b01d796418539e42d578fed635f750cdc1))
+
+- Use keyset pagination by default for `all=True`
+  ([`99b4484`](https://github.com/python-gitlab/python-gitlab/commit/99b4484da924f9378518a1a1194e1a3e75b48073))
+
+- **api**: Add support for GitLab OAuth Applications API
+  ([`4e12356`](https://github.com/python-gitlab/python-gitlab/commit/4e12356d6da58c9ef3d8bf9ae67e8aef8fafac0a))
+
+### Performance Improvements
+
+- Prepare environment when gitlab is reconfigured
+  ([`3834d9c`](https://github.com/python-gitlab/python-gitlab/commit/3834d9cf800a0659433eb640cb3b63a947f0ebda))
+
+### Testing
+
+- Add unit tests for base URLs with trailing slashes
+  ([`32844c7`](https://github.com/python-gitlab/python-gitlab/commit/32844c7b27351b08bb86d8f9bd8fe9cf83917a5a))
+
+- Add unit tests for revert commit API
+  ([`d7a3066`](https://github.com/python-gitlab/python-gitlab/commit/d7a3066e03164af7f441397eac9e8cfef17c8e0c))
+
+- Remove duplicate resp_get_project
+  ([`cb43695`](https://github.com/python-gitlab/python-gitlab/commit/cb436951b1fde9c010e966819c75d0d7adacf17d))
+
+- Use lazy object in unit tests
+  ([`31c6562`](https://github.com/python-gitlab/python-gitlab/commit/31c65621ff592dda0ad3bf854db906beb8a48e9a))
+
+
+## v2.0.1 (2020-02-05)
+
+### Chores
+
+- Bump to 2.1.0
+  ([`a6c0660`](https://github.com/python-gitlab/python-gitlab/commit/a6c06609123a9f4cba1a8605b9c849e4acd69809))
+
+There are a few more features in there
+
+- Bump version to 2.0.1
+  ([`8287a0d`](https://github.com/python-gitlab/python-gitlab/commit/8287a0d993a63501fc859702fc8079a462daa1bb))
+
+- Revert to 2.0.1
+  ([`272db26`](https://github.com/python-gitlab/python-gitlab/commit/272db2655d80fb81fbe1d8c56f241fe9f31b47e0))
+
+I've misread the tag
+
+- **user**: Update user attributes
+  ([`27375f6`](https://github.com/python-gitlab/python-gitlab/commit/27375f6913547cc6e00084e5e77b0ad912b89910))
+
+This also workarounds an GitLab issue, where private_profile, would reset to false if not supplied
+
+### Documentation
+
+- **auth**: Remove email/password auth
+  ([`c9329bb`](https://github.com/python-gitlab/python-gitlab/commit/c9329bbf028c5e5ce175e99859c9e842ab8234bc))
+
+
+## v2.0.0 (2020-01-26)
+
+### Bug Fixes
+
+- **projects**: Adjust snippets to match the API
+  ([`e104e21`](https://github.com/python-gitlab/python-gitlab/commit/e104e213b16ca702f33962d770784f045f36cf10))
+
+### Chores
+
+- Add PyYaml as extra require
+  ([`7ecd518`](https://github.com/python-gitlab/python-gitlab/commit/7ecd5184e62bf1b1f377db161b26fa4580af6b4c))
+
+- Build_sphinx needs sphinx >= 1.7.6
+  ([`528dfab`](https://github.com/python-gitlab/python-gitlab/commit/528dfab211936ee7794f9227311f04656a4d5252))
+
+Stepping thru Sphinx versions from 1.6.5 to 1.7.5 build_sphinx fails. Once Sphinx == 1.7.6
+  build_sphinx finished.
+
+- Bump minimum required requests version
+  ([`3f78aa3`](https://github.com/python-gitlab/python-gitlab/commit/3f78aa3c0d3fc502f295986d4951cfd0eee80786))
+
+for security reasons
+
+- Bump to 2.0.0
+  ([`c817dcc`](https://github.com/python-gitlab/python-gitlab/commit/c817dccde8c104dcb294bbf1590c7e3ae9539466))
+
+Dropping support for legacy python requires a new major version
+
+- Drop legacy python tests
+  ([`af8679a`](https://github.com/python-gitlab/python-gitlab/commit/af8679ac5c2c2b7774d624bdb1981d0e2374edc1))
+
+Support dropped for: 2.7, 3.4, 3.5
+
+- Enforce python version requirements
+  ([`70176db`](https://github.com/python-gitlab/python-gitlab/commit/70176dbbb96a56ee7891885553eb13110197494c))
+
+### Documentation
+
+- Fix snippet get in project
+  ([`3a4ff2f`](https://github.com/python-gitlab/python-gitlab/commit/3a4ff2fbf51d5f7851db02de6d8f0e84508b11a0))
+
+- **projects**: Add raw file download docs
+  ([`939e9d3`](https://github.com/python-gitlab/python-gitlab/commit/939e9d32e6e249e2a642d2bf3c1a34fde288c842))
+
+Fixes #969
+
+### Features
+
+- Add appearance API
+  ([`4c4ac5c`](https://github.com/python-gitlab/python-gitlab/commit/4c4ac5ca1e5cabc4ea4b12734a7b091bc4c224b5))
+
+- Add autocompletion support
+  ([`973cb8b`](https://github.com/python-gitlab/python-gitlab/commit/973cb8b962e13280bcc8473905227cf351661bf0))
+
+- Add global order_by option to ease pagination
+  ([`d187925`](https://github.com/python-gitlab/python-gitlab/commit/d1879253dae93e182710fe22b0a6452296e2b532))
+
+- Support keyset pagination globally
+  ([`0b71ba4`](https://github.com/python-gitlab/python-gitlab/commit/0b71ba4d2965658389b705c1bb0d83d1ff2ee8f2))
+
+### Refactoring
+
+- Remove six dependency
+  ([`9fb4645`](https://github.com/python-gitlab/python-gitlab/commit/9fb46454c6dab1a86ab4492df2368ed74badf7d6))
+
+- Support new list filters
+  ([`bded2de`](https://github.com/python-gitlab/python-gitlab/commit/bded2de51951902444bc62aa016a3ad34aab799e))
+
+This is most likely only useful for the CLI
+
+### Testing
+
+- Add project snippet tests
+  ([`0952c55`](https://github.com/python-gitlab/python-gitlab/commit/0952c55a316fc8f68854badd68b4fc57658af9e7))
+
+- Adjust functional tests for project snippets
+  ([`ac0ea91`](https://github.com/python-gitlab/python-gitlab/commit/ac0ea91f22b08590f85a2b0ffc17cd41ae6e0ff7))
+
+
+## v1.15.0 (2019-12-16)
+
+### Bug Fixes
+
+- Ignore all parameter, when as_list=True
+  ([`137d72b`](https://github.com/python-gitlab/python-gitlab/commit/137d72b3bc00588f68ca13118642ecb5cd69e6ac))
+
+Closes #962
+
+### Chores
+
+- Bump version to 1.15.0
+  ([`2a01326`](https://github.com/python-gitlab/python-gitlab/commit/2a01326e8e02bbf418b3f4c49ffa60c735b107dc))
+
+- **ci**: Use correct crane ci
+  ([`18913dd`](https://github.com/python-gitlab/python-gitlab/commit/18913ddce18f78e7432f4d041ab4bd071e57b256))
+
+### Code Style
+
+- Format with the latest black version
+  ([`06a8050`](https://github.com/python-gitlab/python-gitlab/commit/06a8050571918f0780da4c7d6ae514541118cf1a))
+
+### Documentation
+
+- Added docs for statistics
+  ([`8c84cbf`](https://github.com/python-gitlab/python-gitlab/commit/8c84cbf6374e466f21d175206836672b3dadde20))
+
+- **projects**: Fix file deletion docs
+  ([`1c4f1c4`](https://github.com/python-gitlab/python-gitlab/commit/1c4f1c40185265ae73c52c6d6c418e02ab33204e))
+
+The function `file.delete()` requires `branch` argument in addition to `commit_message`.
+
+### Features
+
+- Access project's issues statistics
+  ([`482e57b`](https://github.com/python-gitlab/python-gitlab/commit/482e57ba716c21cd7b315e5803ecb3953c479b33))
+
+Fixes #966
+
+- Add support for /import/github
+  ([`aa4d41b`](https://github.com/python-gitlab/python-gitlab/commit/aa4d41b70b2a66c3de5a7dd19b0f7c151f906630))
+
+Addresses python-gitlab/python-gitlab#952
+
+This adds a method to the `ProjectManager` called `import_github`, which maps to the
+  `/import/github` API endpoint. Calling `import_github` will trigger an import operation from
+  <repo_id> into <target_namespace>, using <personal_access_token> to authenticate against github.
+  In practice a gitlab server may take many 10's of seconds to respond to this API call, so we also
+  take the liberty of increasing the default timeout (only for this method invocation).
+
+Unfortunately since `import` is a protected keyword in python, I was unable to follow the endpoint
+  structure with the manager namespace. I'm open to suggestions on a more sensible interface.
+
+I'm successfully using this addition to batch-import hundreds of github repositories into gitlab.
+
+- Add variable_type to groups ci variables
+  ([`0986c93`](https://github.com/python-gitlab/python-gitlab/commit/0986c93177cde1f3be77d4f73314c37b14bba011))
+
+This adds the ci variables types for create/update requests.
+
+See https://docs.gitlab.com/ee/api/group_level_variables.html#create-variable
+
+- Add variable_type/protected to projects ci variables
+  ([`4724c50`](https://github.com/python-gitlab/python-gitlab/commit/4724c50e9ec0310432c70f07079b1e03ab3cc666))
+
+This adds the ci variables types and protected flag for create/update requests.
+
+See https://docs.gitlab.com/ee/api/project_level_variables.html#create-variable
+
+- Adding project stats
+  ([`db0b00a`](https://github.com/python-gitlab/python-gitlab/commit/db0b00a905c14d52eaca831fcc9243f33d2f092d))
+
+Fixes #967
+
+- Allow cfg timeout to be overrided via kwargs
+  ([`e9a8289`](https://github.com/python-gitlab/python-gitlab/commit/e9a8289a381ebde7c57aa2364258d84b4771d276))
+
+On startup, the `timeout` parameter is loaded from config and stored on the base gitlab object
+  instance. This instance parameter is used as the timeout for all API requests (it's passed into
+  the `session` object when making HTTP calls).
+
+This change allows any API method to specify a `timeout` argument to `**kwargs` that will override
+  the global timeout value. This was somewhat needed / helpful for the `import_github` method.
+
+I have also updated the docs accordingly.
+
+- Nicer stacktrace
+  ([`697cda2`](https://github.com/python-gitlab/python-gitlab/commit/697cda241509dd76adc1249b8029366cfc1d9d6e))
+
+- Retry transient HTTP errors
+  ([`59fe271`](https://github.com/python-gitlab/python-gitlab/commit/59fe2714741133989a7beed613f1eeb67c18c54e))
+
+Fixes #970
+
+### Testing
+
+- Added tests for statistics
+  ([`8760efc`](https://github.com/python-gitlab/python-gitlab/commit/8760efc89bac394b01218b48dd3fcbef30c8b9a2))
+
+- Test that all is ignored, when as_list=False
+  ([`b5e88f3`](https://github.com/python-gitlab/python-gitlab/commit/b5e88f3e99e2b07e0bafe7de33a8899e97c3bb40))
+
+
+## v1.14.0 (2019-12-07)
+
+### Bug Fixes
+
+- Added missing attributes for project approvals
+  ([`460ed63`](https://github.com/python-gitlab/python-gitlab/commit/460ed63c3dc4f966d6aae1415fdad6de125c6327))
+
+Reference: https://docs.gitlab.com/ee/api/merge_request_approvals.html#change-configuration
+
+Missing attributes: * merge_requests_author_approval * merge_requests_disable_committers_approval
+
+- **labels**: Ensure label.save() works
+  ([`727f536`](https://github.com/python-gitlab/python-gitlab/commit/727f53619dba47f0ab770e4e06f1cb774e14f819))
+
+Otherwise, we get: File "gitlabracadabra/mixins/labels.py", line 67, in _process_labels
+  current_label.save() File "gitlab/exceptions.py", line 267, in wrapped_f return f(*args, **kwargs)
+  File "gitlab/v4/objects.py", line 896, in save self._update_attrs(server_data) File
+  "gitlab/base.py", line 131, in _update_attrs self.__dict__["_attrs"].update(new_attrs) TypeError:
+  'NoneType' object is not iterable
+
+Because server_data is None.
+
+- **project-fork**: Copy create fix from ProjectPipelineManager
+  ([`516307f`](https://github.com/python-gitlab/python-gitlab/commit/516307f1cc9e140c7d85d0ed0c419679b314f80b))
+
+- **project-fork**: Correct path computation for project-fork list
+  ([`44a7c27`](https://github.com/python-gitlab/python-gitlab/commit/44a7c2788dd19c1fe73d7449bd7e1370816fd36d))
+
+### Chores
+
+- Bump version to 1.14.0
+  ([`164fa4f`](https://github.com/python-gitlab/python-gitlab/commit/164fa4f360a1bb0ecf5616c32a2bc31c78c2594f))
+
+- **ci**: Switch to crane docker image
+  ([#944](https://github.com/python-gitlab/python-gitlab/pull/944),
+  [`e0066b6`](https://github.com/python-gitlab/python-gitlab/commit/e0066b6b7c5ce037635f6a803ea26707d5684ef5))
+
+### Documentation
+
+- Add project and group cluster examples
+  ([`d15801d`](https://github.com/python-gitlab/python-gitlab/commit/d15801d7e7742a43ad9517f0ac13b6dba24c6283))
+
+- Fix typo
+  ([`d9871b1`](https://github.com/python-gitlab/python-gitlab/commit/d9871b148c7729c9e401f43ff6293a5e65ce1838))
+
+- **changelog**: Add notice for release-notes on Github
+  ([#938](https://github.com/python-gitlab/python-gitlab/pull/938),
+  [`de98e57`](https://github.com/python-gitlab/python-gitlab/commit/de98e572b003ee4cf2c1ef770a692f442c216247))
+
+- **pipelines_and_jobs**: Add pipeline custom variables usage example
+  ([`b275eb0`](https://github.com/python-gitlab/python-gitlab/commit/b275eb03c5954ca24f249efad8125d1eacadd3ac))
+
+- **readme**: Fix Docker image reference
+  ([`b9a40d8`](https://github.com/python-gitlab/python-gitlab/commit/b9a40d822bcff630a4c92c395c134f8c002ed1cb))
+
+v1.8.0 is not available. ``` Unable to find image
+  'registry.gitlab.com/python-gitlab/python-gitlab:v1.8.0' locally docker: Error response from
+  daemon: manifest for registry.gitlab.com/python-gitlab/python-gitlab:v1.8.0 not found: manifest
+  unknown: manifest unknown.
+
+```
+
+- **snippets**: Fix snippet docs
+  ([`bbaa754`](https://github.com/python-gitlab/python-gitlab/commit/bbaa754673c4a0bffece482fe33e4875ddadc2dc))
+
+Fixes #954
+
+### Features
+
+- Add audit endpoint
+  ([`2534020`](https://github.com/python-gitlab/python-gitlab/commit/2534020b1832f28339ef466d6dd3edc21a521260))
+
+- Add project and group clusters
+  ([`ebd053e`](https://github.com/python-gitlab/python-gitlab/commit/ebd053e7bb695124c8117a95eab0072db185ddf9))
+
+- Add support for include_subgroups filter
+  ([`adbcd83`](https://github.com/python-gitlab/python-gitlab/commit/adbcd83fa172af2f3929ba063a0e780395b102d8))
+
+
+## v1.13.0 (2019-11-02)
+
+### Bug Fixes
+
+- **projects**: Support `approval_rules` endpoint for projects
+  ([`2cef2bb`](https://github.com/python-gitlab/python-gitlab/commit/2cef2bb40b1f37b97bb2ee9894ab3b9970cef231))
+
+The `approvers` API endpoint is deprecated [1]. GitLab instead uses the `approval_rules` API
+  endpoint to modify approval settings for merge requests. This adds the functionality for
+  project-level merge request approval settings.
+
+Note that there does not exist an endpoint to 'get' a single approval rule at this moment - only
+  'list'.
+
+[1] https://docs.gitlab.com/ee/api/merge_request_approvals.html
+
+### Chores
+
+- Bump version to 1.13.0
+  ([`d0750bc`](https://github.com/python-gitlab/python-gitlab/commit/d0750bc01ed12952a4d259a13b3917fa404fd435))
+
+- **ci**: Update latest docker image for every tag
+  ([`01cbc7a`](https://github.com/python-gitlab/python-gitlab/commit/01cbc7ad04a875bea93a08c0ce563ab5b4fe896b))
+
+- **dist**: Add test data
+  ([`3133ed7`](https://github.com/python-gitlab/python-gitlab/commit/3133ed7d1df6f49de380b35331bbcc67b585a61b))
+
+Closes #907
+
+- **setup**: We support 3.8 ([#924](https://github.com/python-gitlab/python-gitlab/pull/924),
+  [`6048175`](https://github.com/python-gitlab/python-gitlab/commit/6048175ef2c21fda298754e9b07515b0a56d66bd))
+
+* chore(setup): we support 3.8
+
+* style: format with black
+
+### Documentation
+
+- Projects get requires id
+  ([`5bd8947`](https://github.com/python-gitlab/python-gitlab/commit/5bd8947bd16398aed218f07458aef72e67f2d130))
+
+Also, add an example value for project_id to the other projects.get() example.
+
+- **project**: Fix group project example
+  ([`e680943`](https://github.com/python-gitlab/python-gitlab/commit/e68094317ff6905049e464a59731fe4ab23521de))
+
+GroupManager.search is removed since 9a66d78, use list(search='keyword') instead
+
+### Features
+
+- Add deployment creation
+  ([`ca256a0`](https://github.com/python-gitlab/python-gitlab/commit/ca256a07a2cdaf77a5c20e307d334b82fd0fe861))
+
+Added in GitLab 12.4
+
+Fixes #917
+
+- Add users activate, deactivate functionality
+  ([`32ad669`](https://github.com/python-gitlab/python-gitlab/commit/32ad66921e408f6553b9d60b6b4833ed3180f549))
+
+These were introduced in GitLab 12.4
+
+- Send python-gitlab version as user-agent
+  ([`c22d49d`](https://github.com/python-gitlab/python-gitlab/commit/c22d49d084d1e03426cfab0d394330f8ab4bd85a))
+
+- **auth**: Remove deprecated session auth
+  ([`b751cdf`](https://github.com/python-gitlab/python-gitlab/commit/b751cdf424454d3859f3f038b58212e441faafaf))
+
+- **doc**: Remove refs to api v3 in docs
+  ([`6beeaa9`](https://github.com/python-gitlab/python-gitlab/commit/6beeaa993f8931d6b7fe682f1afed2bd4c8a4b73))
+
+- **test**: Unused unittest2, type -> isinstance
+  ([`33b1801`](https://github.com/python-gitlab/python-gitlab/commit/33b180120f30515d0f76fcf635cb8c76045b1b42))
+
+### Testing
+
+- Remove warning about open files from test_todo()
+  ([`d6419aa`](https://github.com/python-gitlab/python-gitlab/commit/d6419aa86d6ad385e15d685bf47242bb6c67653e))
+
+When running unittests python warns that the json file from test_todo() was still open. Use with to
+  open, read, and create encoded json data that is used by resp_get_todo().
+
+- **projects**: Support `approval_rules` endpoint for projects
+  ([`94bac44`](https://github.com/python-gitlab/python-gitlab/commit/94bac4494353e4f597df0251f0547513c011e6de))
+
+
+## v1.12.1 (2019-10-07)
+
+### Bug Fixes
+
+- Fix not working without auth
+  ([`03b7b5b`](https://github.com/python-gitlab/python-gitlab/commit/03b7b5b07e1fd2872e8968dd6c29bc3161c6c43a))
+
+
+## v1.12.0 (2019-10-06)
+
+### Bug Fixes
+
+- **cli**: Fix cli command user-project list
+  ([`c17d7ce`](https://github.com/python-gitlab/python-gitlab/commit/c17d7ce14f79c21037808894d8c7ba1117779130))
+
+- **labels**: Don't mangle label name on update
+  ([`1fb6f73`](https://github.com/python-gitlab/python-gitlab/commit/1fb6f73f4d501c2b6c86c863d40481e1d7a707fe))
+
+- **todo**: Mark_all_as_done doesn't return anything
+  ([`5066e68`](https://github.com/python-gitlab/python-gitlab/commit/5066e68b398039beb5e1966ba1ed7684d97a8f74))
+
+### Chores
+
+- Bump to 1.12.0
+  ([`4648128`](https://github.com/python-gitlab/python-gitlab/commit/46481283a9985ae1b07fe686ec4a34e4a1219b66))
+
+- **ci**: Build test images on tag
+  ([`0256c67`](https://github.com/python-gitlab/python-gitlab/commit/0256c678ea9593c6371ffff60663f83c423ca872))
+
+### Code Style
+
+- Format with black
+  ([`fef085d`](https://github.com/python-gitlab/python-gitlab/commit/fef085dca35d6b60013d53a3723b4cbf121ab2ae))
+
+### Documentation
+
+- **project**: Add submodule docs
+  ([`b5969a2`](https://github.com/python-gitlab/python-gitlab/commit/b5969a2dcea77fa608cc29be7a5f39062edd3846))
+
+- **projects**: Add note about project list
+  ([`44407c0`](https://github.com/python-gitlab/python-gitlab/commit/44407c0f59b9602b17cfb93b5e1fa37a84064766))
+
+Fixes #795
+
+- **repository-tags**: Fix typo
+  ([`3024c5d`](https://github.com/python-gitlab/python-gitlab/commit/3024c5dc8794382e281b83a8266be7061069e83e))
+
+Closes #879
+
+- **todo**: Correct todo docs
+  ([`d64edcb`](https://github.com/python-gitlab/python-gitlab/commit/d64edcb4851ea62e72e3808daf7d9b4fdaaf548b))
+
+### Features
+
+- Add support for job token
+  ([`cef3aa5`](https://github.com/python-gitlab/python-gitlab/commit/cef3aa51a6928338c6755c3e6de78605fae8e59e))
+
+See https://docs.gitlab.com/ee/api/jobs.html#get-job-artifacts for usage
+
+- **ci**: Improve functionnal tests
+  ([`eefceac`](https://github.com/python-gitlab/python-gitlab/commit/eefceace2c2094ef41d3da2bf3c46a58a450dcba))
+
+- **project**: Add file blame api
+  ([`f5b4a11`](https://github.com/python-gitlab/python-gitlab/commit/f5b4a113a298d33cb72f80c94d85bdfec3c4e149))
+
+https://docs.gitlab.com/ee/api/repository_files.html#get-file-blame-from-repository
+
+- **project**: Implement update_submodule
+  ([`4d1e377`](https://github.com/python-gitlab/python-gitlab/commit/4d1e3774706f336e87ebe70e1b373ddb37f34b45))
+
+- **user**: Add status api
+  ([`62c9fe6`](https://github.com/python-gitlab/python-gitlab/commit/62c9fe63a47ddde2792a4a5e9cd1c7aa48661492))
+
+### Refactoring
+
+- Remove obsolete test image
+  ([`a14c02e`](https://github.com/python-gitlab/python-gitlab/commit/a14c02ef85bd4d273b8c7f0f6bd07680c91955fa))
+
+Follow up of #896
+
+- Remove unused code, simplify string format
+  ([`c7ff676`](https://github.com/python-gitlab/python-gitlab/commit/c7ff676c11303a00da3a570bf2893717d0391f20))
+
+### Testing
+
+- Re-enabled py_func_v4 test
+  ([`49d84ba`](https://github.com/python-gitlab/python-gitlab/commit/49d84ba7e95fa343e622505380b3080279b83f00))
+
+- **func**: Disable commit test
+  ([`c9c76a2`](https://github.com/python-gitlab/python-gitlab/commit/c9c76a257d2ed3b394f499253d890c2dd9a01e24))
+
+GitLab seems to be randomly failing here
+
+- **status**: Add user status test
+  ([`fec4f9c`](https://github.com/python-gitlab/python-gitlab/commit/fec4f9c23b8ba33bb49dca05d9c3e45cb727e0af))
+
+- **submodules**: Correct test method
+  ([`e59356f`](https://github.com/python-gitlab/python-gitlab/commit/e59356f6f90d5b01abbe54153441b6093834aa11))
+
+- **todo**: Add unittests
+  ([`7715567`](https://github.com/python-gitlab/python-gitlab/commit/77155678a5d8dbbf11d00f3586307694042d3227))
+
+
+## v1.11.0 (2019-08-31)
+
+### Bug Fixes
+
+- Add project and group label update without id to fix cli
+  ([`a3d0d7c`](https://github.com/python-gitlab/python-gitlab/commit/a3d0d7c1e7b259a25d9dc84c0b1de5362c80abb8))
+
+- Remove empty dict default arguments
+  ([`8fc8e35`](https://github.com/python-gitlab/python-gitlab/commit/8fc8e35c63d7ebd80408ae002693618ca16488a7))
+
+Signed-off-by: Frantisek Lachman <flachman@redhat.com>
+
+- Remove empty list default arguments
+  ([`6e204ce`](https://github.com/python-gitlab/python-gitlab/commit/6e204ce819fc8bdd5359325ed7026a48d63f8103))
+
+Signed-off-by: Frantisek Lachman <flachman@redhat.com>
+
+- **projects**: Avatar uploading for projects
+  ([`558ace9`](https://github.com/python-gitlab/python-gitlab/commit/558ace9b007ff9917734619c05a7c66008a4c3f0))
+
+### Chores
+
+- Bump package version
+  ([`37542cd`](https://github.com/python-gitlab/python-gitlab/commit/37542cd28aa94ba01d5d289d950350ec856745af))
+
+### Features
+
+- Add methods to retrieve an individual project environment
+  ([`29de40e`](https://github.com/python-gitlab/python-gitlab/commit/29de40ee6a20382c293d8cdc8d831b52ad56a657))
+
+- Group labels with subscriptable mixin
+  ([`4a9ef9f`](https://github.com/python-gitlab/python-gitlab/commit/4a9ef9f0fa26e01fc6c97cf88b2a162e21f61cce))
+
+### Testing
+
+- Add group label cli tests
+  ([`f7f24bd`](https://github.com/python-gitlab/python-gitlab/commit/f7f24bd324eaf33aa3d1d5dd12719237e5bf9816))
+
+
+## v1.10.0 (2019-07-22)
+
+### Bug Fixes
+
+- Convert # to %23 in URLs
+  ([`14f5385`](https://github.com/python-gitlab/python-gitlab/commit/14f538501bfb47c92e02e615d0817675158db3cf))
+
+Refactor a bit to handle this change, and add unit tests.
+
+Closes #779
+
+- Docker entry point argument passing
+  ([`67ab637`](https://github.com/python-gitlab/python-gitlab/commit/67ab6371e69fbf137b95fd03105902206faabdac))
+
+Fixes the problem of passing spaces in the arguments to the docker entrypoint.
+
+Before this fix, there was virtually no way to pass spaces in arguments such as task description.
+
+- Enable use of YAML in the CLI
+  ([`ad0b476`](https://github.com/python-gitlab/python-gitlab/commit/ad0b47667f98760d6a802a9d08b2da8f40d13e87))
+
+In order to use the YAML output, PyYaml needs to be installed on the docker image. This commit adds
+  the installation to the dockerfile as a separate layer.
+
+- Handle empty 'Retry-After' header from GitLab
+  ([`7a3724f`](https://github.com/python-gitlab/python-gitlab/commit/7a3724f3fca93b4f55aed5132cf46d3718c4f594))
+
+When requests are throttled (HTTP response code 429), python-gitlab assumed that 'Retry-After'
+  existed in the response headers. This is not always the case and so the request fails due to a
+  KeyError. The change in this commit adds a rudimentary exponential backoff to the 'http_request'
+  method, which defaults to 10 retries but can be set to -1 to retry without bound.
+
+- Improve pickle support
+  ([`b4b5dec`](https://github.com/python-gitlab/python-gitlab/commit/b4b5decb7e49ac16d98d56547a874fb8f9d5492b))
+
+- Pep8 errors
+  ([`334f9ef`](https://github.com/python-gitlab/python-gitlab/commit/334f9efb18c95bb5df3271d26fa0a55b7aec1c7a))
+
+Errors have not been detected by broken travis runs.
+
+- Re-add merge request pipelines
+  ([`877ddc0`](https://github.com/python-gitlab/python-gitlab/commit/877ddc0dbb664cd86e870bb81d46ca614770b50e))
+
+- Remove decode() on error_message string
+  ([`16bda20`](https://github.com/python-gitlab/python-gitlab/commit/16bda20514e036e51bef210b565671174cdeb637))
+
+The integration tests failed because a test called 'decode()' on a string-type variable - the
+  GitLabException class handles byte-to-string conversion already in its __init__. This commit
+  removes the call to 'decode()' in the test.
+
+``` Traceback (most recent call last): File "./tools/python_test_v4.py", line 801, in <module>
+  assert 'Retry later' in error_message.decode() AttributeError: 'str' object has no attribute
+  'decode'
+
+```
+
+- Use python2 compatible syntax for super
+  ([`b08efcb`](https://github.com/python-gitlab/python-gitlab/commit/b08efcb9d155c20fa938534dd2d912f5191eede6))
+
+- **api**: Avoid parameter conflicts with python and gitlab
+  ([`4bd027a`](https://github.com/python-gitlab/python-gitlab/commit/4bd027aac41c41f7e22af93c7be0058d2faf7fb4))
+
+Provide another way to send data to gitlab with a new `query_parameters` argument. This parameter
+  can be used to explicitly define the dict of items to send to the server, so that **kwargs are
+  only used to specify python-gitlab specific parameters.
+
+Closes #566 Closes #629
+
+- **api**: Don't try to parse raw downloads
+  ([`35a6d85`](https://github.com/python-gitlab/python-gitlab/commit/35a6d85acea4776e9c4ad23ff75259481a6bcf8d))
+
+http_get always tries to interpret the retrieved data if the content-type is json. In some cases
+  (artifact download for instance) this is not the expected behavior.
+
+This patch changes http_get and download methods to always get the raw data without parsing.
+
+Closes #683
+
+- **api**: Make *MemberManager.all() return a list of objects
+  ([`d74ff50`](https://github.com/python-gitlab/python-gitlab/commit/d74ff506ca0aadaba3221fc54cbebb678240564f))
+
+Fixes #699
+
+- **api**: Make reset_time_estimate() work again
+  ([`cb388d6`](https://github.com/python-gitlab/python-gitlab/commit/cb388d6e6d5ec6ef1746edfffb3449c17e31df34))
+
+Closes #672
+
+- **cli**: Allow --recursive parameter in repository tree
+  ([`7969a78`](https://github.com/python-gitlab/python-gitlab/commit/7969a78ce8605c2b0195734e54c7d12086447304))
+
+Fixes #718 Fixes #731
+
+- **cli**: Don't fail when the short print attr value is None
+  ([`8d1552a`](https://github.com/python-gitlab/python-gitlab/commit/8d1552a0ad137ca5e14fabfc75f7ca034c2a78ca))
+
+Fixes #717 Fixes #727
+
+- **cli**: Exit on config parse error, instead of crashing
+  ([`6ad9da0`](https://github.com/python-gitlab/python-gitlab/commit/6ad9da04496f040ae7d95701422434bc935a5a80))
+
+* Exit and hint user about possible errors * test: adjust test cases to config missing error
+
+- **cli**: Fix update value for key not working
+  ([`b766203`](https://github.com/python-gitlab/python-gitlab/commit/b7662039d191ebb6a4061c276e78999e2da7cd3f))
+
+- **cli**: Print help and usage without config file
+  ([`6bb4d17`](https://github.com/python-gitlab/python-gitlab/commit/6bb4d17a92832701b9f064a6577488cc42d20645))
+
+Fixes #560
+
+- **docker**: Use docker image with current sources
+  ([`06e8ca8`](https://github.com/python-gitlab/python-gitlab/commit/06e8ca8747256632c8a159f760860b1ae8f2b7b5))
+
+### Chores
+
+- Add a tox job to run black
+  ([`c27fa48`](https://github.com/python-gitlab/python-gitlab/commit/c27fa486698e441ebc16448ee93e5539cb885ced))
+
+Allow lines to be 88 chars long for flake8.
+
+- Bump package version to 1.10.0
+  ([`c7c8470`](https://github.com/python-gitlab/python-gitlab/commit/c7c847056b6d24ba7a54b93837950b7fdff6c477))
+
+- Disable failing travis test
+  ([`515aa9a`](https://github.com/python-gitlab/python-gitlab/commit/515aa9ac2aba132d1dfde0418436ce163fca2313))
+
+- Move checks back to travis
+  ([`b764525`](https://github.com/python-gitlab/python-gitlab/commit/b7645251a0d073ca413bba80e87884cc236e63f2))
+
+- Release tags to PyPI automatically
+  ([`3133b48`](https://github.com/python-gitlab/python-gitlab/commit/3133b48a24ce3c9e2547bf2a679d73431dfbefab))
+
+Fixes #609
+
+- **ci**: Add automatic GitLab image pushes
+  ([`95c9b6d`](https://github.com/python-gitlab/python-gitlab/commit/95c9b6dd489fc15c7dfceffca909917f4f3d4312))
+
+- **ci**: Don't try to publish existing release
+  ([`b4e818d`](https://github.com/python-gitlab/python-gitlab/commit/b4e818db7887ff1ec337aaf392b5719f3931bc61))
+
+- **ci**: Fix gitlab PyPI publish
+  ([`3e37df1`](https://github.com/python-gitlab/python-gitlab/commit/3e37df16e2b6a8f1beffc3a595abcb06fd48a17c))
+
+- **ci**: Rebuild test image, when something changed
+  ([`2fff260`](https://github.com/python-gitlab/python-gitlab/commit/2fff260a8db69558f865dda56f413627bb70d861))
+
+- **ci**: Update the GitLab version in the test image
+  ([`c410699`](https://github.com/python-gitlab/python-gitlab/commit/c41069992de392747ccecf8c282ac0549932ccd1))
+
+- **ci**: Use reliable ci system
+  ([`724a672`](https://github.com/python-gitlab/python-gitlab/commit/724a67211bc83d67deef856800af143f1dbd1e78))
+
+- **setup**: Add 3.7 to supported python versions
+  ([`b1525c9`](https://github.com/python-gitlab/python-gitlab/commit/b1525c9a4ca2d8c6c14d745638b3292a71763aeb))
+
+- **tests**: Add rate limit tests
+  ([`e216f06`](https://github.com/python-gitlab/python-gitlab/commit/e216f06d4d25d37a67239e93a8e2e400552be396))
+
+### Code Style
+
+- Format with black again
+  ([`22b5082`](https://github.com/python-gitlab/python-gitlab/commit/22b50828d6936054531258f3dc17346275dd0aee))
+
+### Documentation
+
+- Add a note for python 3.5 for file content update
+  ([`ca014f8`](https://github.com/python-gitlab/python-gitlab/commit/ca014f8c3e4877a4cc1ae04e1302fb57d39f47c4))
+
+The data passed to the JSON serializer must be a string with python 3. Document this in the
+  exemples.
+
+Fix #175
+
+- Add an example of trigger token usage
+  ([`ea1eefe`](https://github.com/python-gitlab/python-gitlab/commit/ea1eefef2896420ae4e4d248155e4c5d33b4034e))
+
+Closes #752
+
+- Add ApplicationSettings API
+  ([`ab7d794`](https://github.com/python-gitlab/python-gitlab/commit/ab7d794251bcdbafce69b1bde0628cd3b710d784))
+
+- Add builds-related API docs
+  ([`8e6a944`](https://github.com/python-gitlab/python-gitlab/commit/8e6a9442324926ed1dec0a8bfaf77792e4bdb10f))
+
+- Add deploy keys API
+  ([`ea089e0`](https://github.com/python-gitlab/python-gitlab/commit/ea089e092439a8fe95b50c3d0592358550389b51))
+
+- Add labales API
+  ([`31882b8`](https://github.com/python-gitlab/python-gitlab/commit/31882b8a57f3f4c7e4c4c4b319af436795ebafd3))
+
+- Add licenses API
+  ([`4540614`](https://github.com/python-gitlab/python-gitlab/commit/4540614a38067944c628505225bb15928d8e3c93))
+
+- Add milestones API
+  ([`7411907`](https://github.com/python-gitlab/python-gitlab/commit/74119073dae18214df1dd67ded6cd57abda335d4))
+
+- Add missing =
+  ([`391417c`](https://github.com/python-gitlab/python-gitlab/commit/391417cd47d722760dfdaab577e9f419c5dca0e0))
+
+- Add missing requiredCreateAttrs
+  ([`b08d74a`](https://github.com/python-gitlab/python-gitlab/commit/b08d74ac3efb505961971edb998ce430e430d652))
+
+- Add MR API
+  ([`5614a7c`](https://github.com/python-gitlab/python-gitlab/commit/5614a7c9bf62aede3804469b6781f45d927508ea))
+
+- Add MR approvals in index
+  ([`0b45afb`](https://github.com/python-gitlab/python-gitlab/commit/0b45afbeed13745a2f9d8a6ec7d09704a6ab44fb))
+
+- Add pipeline deletion
+  ([`2bb2571`](https://github.com/python-gitlab/python-gitlab/commit/2bb257182c237384d60b8d90cbbff5a0598f283b))
+
+- Add project members doc
+  ([`dcf31a4`](https://github.com/python-gitlab/python-gitlab/commit/dcf31a425217efebe56d4cbc8250dceb3844b2fa))
+
+- Commits API
+  ([`07c5594`](https://github.com/python-gitlab/python-gitlab/commit/07c55943eebb302bc1b8feaf482d929c83e9ebe1))
+
+- Crossref improvements
+  ([`6f9f42b`](https://github.com/python-gitlab/python-gitlab/commit/6f9f42b64cb82929af60e299c70773af6d406a6e))
+
+- Do not use the :option: markup
+  ([`368017c`](https://github.com/python-gitlab/python-gitlab/commit/368017c01f15013ab4cc9405c246a86e67f3b067))
+
+- Document hooks API
+  ([`b21dca0`](https://github.com/python-gitlab/python-gitlab/commit/b21dca0acb2c12add229a1742e0c552aa50618c1))
+
+- Document projects API
+  ([`967595f`](https://github.com/python-gitlab/python-gitlab/commit/967595f504b8de076ae9218a96c3b8dd6273b9d6))
+
+- Fix "required" attribute
+  ([`e64d0b9`](https://github.com/python-gitlab/python-gitlab/commit/e64d0b997776387f400eaec21c37ce6e58d49095))
+
+- Fix invalid Raise attribute in docstrings
+  ([`95a3fe6`](https://github.com/python-gitlab/python-gitlab/commit/95a3fe6907676109e1cd2f52ca8f5ad17e0d01d0))
+
+- Fork relationship API
+  ([`21f48b3`](https://github.com/python-gitlab/python-gitlab/commit/21f48b357130720551d5cccbc62f5275fe970378))
+
+- Groups API documentation
+  ([`4d871aa`](https://github.com/python-gitlab/python-gitlab/commit/4d871aadfaa9f57f5ae9f8b49f8367a5ef58545d))
+
+- Improve the pagination section
+  ([`29e2efe`](https://github.com/python-gitlab/python-gitlab/commit/29e2efeae22ce5fa82e3541360b234e0053a65c2))
+
+- Issues API
+  ([`41cbc32`](https://github.com/python-gitlab/python-gitlab/commit/41cbc32621004aab2cae5f7c14fc60005ef7b966))
+
+- Notes API
+  ([`3e026d2`](https://github.com/python-gitlab/python-gitlab/commit/3e026d2ee62eba3ad92ff2cdd53db19f5e0e9f6a))
+
+- Project repository API
+  ([`71a2a4f`](https://github.com/python-gitlab/python-gitlab/commit/71a2a4fb84321e73418fda1ce4e4d47177af928c))
+
+- Project search API
+  ([`e4cd04c`](https://github.com/python-gitlab/python-gitlab/commit/e4cd04c225e2160f02a8f292dbd4c0f6350769e4))
+
+- Re-order api examples
+  ([`5d149a2`](https://github.com/python-gitlab/python-gitlab/commit/5d149a2262653b729f0105639ae5027ae5a109ea))
+
+`Pipelines and Jobs` and `Protected Branches` are out of order in contents and sometimes hard to
+  find when looking for examples.
+
+- Remove the build warning about _static
+  ([`764d3ca`](https://github.com/python-gitlab/python-gitlab/commit/764d3ca0087f0536c48c9e1f60076af211138b9b))
+
+- Remove v3 support
+  ([`7927663`](https://github.com/python-gitlab/python-gitlab/commit/792766319f7c43004460fc9b975549be55430987))
+
+- Repository files API
+  ([`f00340f`](https://github.com/python-gitlab/python-gitlab/commit/f00340f72935b6fd80df7b62b811644b63049b5a))
+
+- Snippets API
+  ([`35b7f75`](https://github.com/python-gitlab/python-gitlab/commit/35b7f750c7e38a39cd4cb27195d9aa4807503b29))
+
+- Start a FAQ
+  ([`c305459`](https://github.com/python-gitlab/python-gitlab/commit/c3054592f79caa782ec79816501335e9a5c4e9ed))
+
+- System hooks API
+  ([`5c51bf3`](https://github.com/python-gitlab/python-gitlab/commit/5c51bf3d49302afe4725575a83d81a8c9eeb8779))
+
+- Tags API
+  ([`dd79eda`](https://github.com/python-gitlab/python-gitlab/commit/dd79eda78f91fc7e1e9a08b1e70ef48e3b4bb06d))
+
+- Trigger_pipeline only accept branches and tags as ref
+  ([`d63748a`](https://github.com/python-gitlab/python-gitlab/commit/d63748a41cc22bba93a9adf0812e7eb7b74a0161))
+
+Fixes #430
+
+- **api-usage**: Add rate limit documentation
+  ([`ad4de20`](https://github.com/python-gitlab/python-gitlab/commit/ad4de20fe3a2fba2d35d4204bf5b0b7f589d4188))
+
+- **api-usage**: Fix project group example
+  ([`40a1bf3`](https://github.com/python-gitlab/python-gitlab/commit/40a1bf36c2df89daa1634e81c0635c1a63831090))
+
+Fixes #798
+
+- **cli**: Add PyYAML requirement notice
+  ([`d29a489`](https://github.com/python-gitlab/python-gitlab/commit/d29a48981b521bf31d6f0304b88f39a63185328a))
+
+Fixes #606
+
+- **groups**: Fix typo
+  ([`ac2d65a`](https://github.com/python-gitlab/python-gitlab/commit/ac2d65aacba5c19eca857290c5b47ead6bb4356d))
+
+Fixes #635
+
+- **projects**: Add mention about project listings
+  ([`f604b25`](https://github.com/python-gitlab/python-gitlab/commit/f604b2577b03a6a19641db3f2060f99d24cc7073))
+
+Having exactly 20 internal and 5 private projects in the group spent some time debugging this issue.
+
+Hopefully that helped: https://github.com/python-gitlab/python-gitlab/issues/93
+
+Imho should be definitely mention about `all=True` parameter.
+
+- **projects**: Fix typo
+  ([`c6bcfe6`](https://github.com/python-gitlab/python-gitlab/commit/c6bcfe6d372af6557547a408a8b0a39b909f0cdf))
+
+- **projects**: Fix typo in code sample
+  ([`b93f2a9`](https://github.com/python-gitlab/python-gitlab/commit/b93f2a9ea9661521878ac45d70c7bd9a5a470548))
+
+Fixes #630
+
+- **readme**: Add docs build information
+  ([`6585c96`](https://github.com/python-gitlab/python-gitlab/commit/6585c967732fe2a53c6ad6d4d2ab39aaa68258b0))
+
+- **readme**: Add more info about commitlint, code-format
+  ([`286f703`](https://github.com/python-gitlab/python-gitlab/commit/286f7031ed542c97fb8792f61012d7448bee2658))
+
+- **readme**: Fix six url
+  ([`0bc30f8`](https://github.com/python-gitlab/python-gitlab/commit/0bc30f840c9c30dd529ae85bdece6262d2702c94))
+
+six URL was pointing to 404
+
+- **readme**: Provide commit message guidelines
+  ([`bed8e1b`](https://github.com/python-gitlab/python-gitlab/commit/bed8e1ba80c73b1d976ec865756b62e66342ce32))
+
+Fixes #660
+
+- **setup**: Use proper readme on PyPI
+  ([`6898097`](https://github.com/python-gitlab/python-gitlab/commit/6898097c45d53a3176882a3d9cb86c0015f8d491))
+
+- **snippets**: Fix project-snippets layout
+  ([`7feb97e`](https://github.com/python-gitlab/python-gitlab/commit/7feb97e9d89b4ef1401d141be3d00b9d0ff6b75c))
+
+Fixes #828
+
+### Features
+
+- Add endpoint to get the variables of a pipeline
+  ([`564de48`](https://github.com/python-gitlab/python-gitlab/commit/564de484f5ef4c76261057d3d2207dc747da020b))
+
+It adds a new endpoint which was released in the Gitlab CE 11.11.
+
+Signed-off-by: Agustin Henze <tin@redhat.com>
+
+- Add mr rebase method
+  ([`bc4280c`](https://github.com/python-gitlab/python-gitlab/commit/bc4280c2fbff115bd5e29a6f5012ae518610f626))
+
+- Add support for board update
+  ([`908d79f`](https://github.com/python-gitlab/python-gitlab/commit/908d79fa56965e7b3afcfa23236beef457cfa4b4))
+
+Closes #801
+
+- Add support for issue.related_merge_requests
+  ([`90a3631`](https://github.com/python-gitlab/python-gitlab/commit/90a363154067bcf763043124d172eaf705c8fe90))
+
+Closes #794
+
+- Added approve & unapprove method for Mergerequests
+  ([`53f7de7`](https://github.com/python-gitlab/python-gitlab/commit/53f7de7bfe0056950a8e7271632da3f89e3ba3b3))
+
+Offical GitLab API supports this for GitLab EE
+
+- Bump version to 1.9.0
+  ([`aaed448`](https://github.com/python-gitlab/python-gitlab/commit/aaed44837869bd2ce22b6f0d2e1196b1d0e626a6))
+
+- Get artifact by ref and job
+  ([`cda1174`](https://github.com/python-gitlab/python-gitlab/commit/cda117456791977ad300a1dd26dec56009dac55e))
+
+- Implement artifacts deletion
+  ([`76b6e1f`](https://github.com/python-gitlab/python-gitlab/commit/76b6e1fc0f42ad00f21d284b4ca2c45d6020fd19))
+
+Closes #744
+
+- Obey the rate limit
+  ([`2abf9ab`](https://github.com/python-gitlab/python-gitlab/commit/2abf9abacf834da797f2edf6866e12886d642b9d))
+
+done by using the retry-after header
+
+Fixes #166
+
+- **GitLab Update**: Delete ProjectPipeline
+  ([#736](https://github.com/python-gitlab/python-gitlab/pull/736),
+  [`768ce19`](https://github.com/python-gitlab/python-gitlab/commit/768ce19c5e5bb197cddd4e3871c175e935c68312))
+
+* feat(GitLab Update): delete ProjectPipeline
+
+As of Gitlab 11.6 it is now possible to delete a pipeline -
+  https://docs.gitlab.com/ee/api/pipelines.html#delete-a-pipeline
+
+### Refactoring
+
+- Format everything black
+  ([`318d277`](https://github.com/python-gitlab/python-gitlab/commit/318d2770cbc90ae4d33170274e214b9d828bca43))
+
+- Rename MASTER_ACCESS
+  ([`c38775a`](https://github.com/python-gitlab/python-gitlab/commit/c38775a5d52620a9c2d506d7b0952ea7ef0a11fc))
+
+to MAINTAINER_ACCESS to follow GitLab 11.0 docs
+
+See: https://docs.gitlab.com/ce/user/permissions.html#project-members-permissions
+
+### Testing
+
+- Add project releases test
+  ([`8ff8af0`](https://github.com/python-gitlab/python-gitlab/commit/8ff8af0d02327125fbfe1cfabe0a09f231e64788))
+
+Fixes #762
+
+- Always use latest version to test
+  ([`82b0fc6`](https://github.com/python-gitlab/python-gitlab/commit/82b0fc6f3884f614912a6440f4676dfebee12d8e))
+
+- Increase speed by disabling the rate limit faster
+  ([`497f56c`](https://github.com/python-gitlab/python-gitlab/commit/497f56c3e1b276fb9499833da0cebfb3b756d03b))
+
+- Minor test fixes
+  ([`3b523f4`](https://github.com/python-gitlab/python-gitlab/commit/3b523f4c39ba4b3eacc9e76fcb22de7b426d2f45))
+
+- Update the tests for GitLab 11.11
+  ([`622854f`](https://github.com/python-gitlab/python-gitlab/commit/622854fc22c31eee988f8b7f59dbc033ff9393d6))
+
+Changes in GitLab make the functional tests fail:
+
+* Some actions add new notes and discussions: do not use hardcoded values in related listing asserts
+  * The feature flag API is buggy (errors 500): disable the tests for now
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 000000000..9472092bf
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,134 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, caste, color, religion, or sexual
+identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes,
+  and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the overall
+  community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or advances of
+  any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email address,
+  without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+  professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official e-mail address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement by using GitHub's
+[Report Content](https://docs.github.com/en/communities/maintaining-your-safety-on-github/reporting-abuse-or-spam)
+functionality or contacting the currently active maintainers listed in
+[AUTHORS](https://github.com/python-gitlab/python-gitlab/blob/main/AUTHORS).
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series of
+actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or permanent
+ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within the
+community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.1, available at
+[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
+
+Community Impact Guidelines were inspired by
+[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
+
+For answers to common questions about this code of conduct, see the FAQ at
+[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
+[https://www.contributor-covenant.org/translations][translations].
+
+[homepage]: https://www.contributor-covenant.org
+[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
+[Mozilla CoC]: https://github.com/mozilla/diversity
+[FAQ]: https://www.contributor-covenant.org/faq
+[translations]: https://www.contributor-covenant.org/translations
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
new file mode 100644
index 000000000..9b07ada11
--- /dev/null
+++ b/CONTRIBUTING.rst
@@ -0,0 +1,231 @@
+Contributing
+============
+
+You can contribute to the project in multiple ways:
+
+* Write documentation
+* Implement features
+* Fix bugs
+* Add unit and functional tests
+* Everything else you can think of
+
+Issue Management and Our Approach to Contributions
+--------------------------------------------------
+
+We value every contribution and bug report. However, as an open-source project
+with limited maintainer resources, we rely heavily on the community to help us
+move forward.
+
+**Our Policy on Inactive Issues:**
+
+To keep our issue tracker manageable and focused on actionable items, we have
+the following approach:
+
+* **We encourage reporters to propose solutions:** If you report an issue, we
+  strongly encourage you to also think about how it might be fixed and try to
+  implement that fix.
+* **Community interest is key:** Issues that garner interest from the community
+  (e.g., multiple users confirming, discussions on solutions, offers to help)
+  are more likely to be addressed.
+* **Closing inactive issues:** If an issue report doesn't receive a proposed
+  fix from the original reporter or anyone else in the community, and there's
+  no active discussion or indication that someone is willing to work on it
+  after a reasonable period, it may be closed.
+
+  * When closing such an issue, we will typically leave a comment explaining
+    that it's being closed due to inactivity and a lack of a proposed fix.
+
+* **Reopening issues:** This doesn't mean the issue isn't valid. If you (or
+  someone else) are interested in working on a fix for a closed issue, please
+  comment on the issue. We are more than happy to reopen it and discuss your
+  proposed pull request or solution. We greatly appreciate it when community
+  members take ownership of fixing issues they care about.
+
+We believe this approach helps us focus our efforts effectively and empowers
+the community to contribute directly to the areas they are most passionate
+about.
+
+Development workflow
+--------------------
+
+Before contributing, install `tox <https://tox.wiki/>`_ and `pre-commit <https://pre-commit.com>`_:
+
+.. code-block:: bash
+
+  pip3 install --user tox pre-commit
+  cd python-gitlab/
+  pre-commit install -t pre-commit -t commit-msg --install-hooks
+
+This will help automate adhering to code style and commit message guidelines described below.
+
+If you don't like using ``pre-commit``, feel free to skip installing it, but please **ensure all your
+commit messages and code pass all default tox checks** outlined below before pushing your code.
+
+When you're ready or if you'd like to get feedback, please provide your patches as Pull Requests on GitHub.
+
+Commit message guidelines
+-------------------------
+
+We enforce commit messages to be formatted using the `Conventional Commits <https://www.conventionalcommits.org/>`_.
+This creates a clearer project history, and automates our `Releases`_ and changelog generation. Examples:
+
+* Bad:   ``Added support for release links``
+* Good:  ``feat(api): add support for release links``
+
+* Bad:   ``Update documentation for projects``
+* Good:  ``docs(projects): update example for saving project attributes``
+
+Coding Style
+------------
+
+We use `black <https://github.com/python/black/>`_ and `isort <https://pycqa.github.io/isort/>`_
+to format our code, so you'll need to make sure you use it when committing.
+
+Pre-commit hooks will validate and format your code, so you can then stage any changes done if the commit failed.
+
+To format your code according to our guidelines before committing, run:
+
+.. code-block:: bash
+
+  cd python-gitlab/
+  tox -e black,isort
+
+Running unit tests
+------------------
+
+Before submitting a pull request make sure that the tests and lint checks still succeed with
+your change. Unit tests and functional tests run in GitHub Actions and
+passing checks are mandatory to get merge requests accepted.
+
+Please write new unit tests with pytest and using `responses
+<https://github.com/getsentry/responses/>`_.
+An example can be found in ``tests/unit/objects/test_runner.py``
+
+You need to install ``tox`` (``pip3 install tox``) to run tests and lint checks locally:
+
+.. code-block:: bash
+
+   # run unit tests using all python3 versions available on your system, and all lint checks:
+   tox
+
+   # run unit tests in one python environment only (useful for quick testing during development):
+   tox -e py311
+
+   # run unit and smoke tests in one python environment only
+   tox -e py312,smoke
+
+   # build the documentation - the result will be generated in build/sphinx/html/:
+   tox -e docs
+
+   # build and serve the documentation site locally for validating changes
+   tox -e docs-serve
+
+   # List all available tox environments
+   tox list
+
+   # "label" based tests. These use the '-m' flag to tox
+
+   # run all the linter checks:
+   tox -m lint
+
+   # run only the unit tests:
+   tox -m unit
+
+   # run the functional tests. This is very time consuming:
+   tox -m func
+
+Running integration tests
+-------------------------
+
+Integration tests run against a running gitlab instance, using a docker
+container. You need to have docker installed on the test machine, and your user
+must have the correct permissions to talk to the docker daemon.
+
+To run these tests:
+
+.. code-block:: bash
+
+   # run the CLI tests:
+   tox -e cli_func_v4
+
+   # run the python API tests:
+   tox -e api_func_v4
+
+When developing tests it can be a little frustrating to wait for GitLab to spin
+up every run. To prevent the containers from being cleaned up afterwards, pass
+``--keep-containers`` to pytest, i.e.:
+
+.. code-block:: bash
+
+   tox -e api_func_v4 -- --keep-containers
+
+If you then wish to test against a clean slate, you may perform a manual clean
+up of the containers by running:
+
+.. code-block:: bash
+
+   docker-compose -f tests/functional/fixtures/docker-compose.yml -p pytest-python-gitlab down -v
+
+By default, the tests run against the latest version of the ``gitlab/gitlab-ce``
+image. You can override both the image and tag by providing either the
+``GITLAB_IMAGE`` or ``GITLAB_TAG`` environment variables.
+
+This way you can run tests against different versions, such as ``nightly`` for
+features in an upcoming release, or an older release (e.g. ``12.8.0-ce.0``).
+The tag must match an exact tag on Docker Hub:
+
+.. code-block:: bash
+
+   # run tests against ``nightly`` or specific tag
+   GITLAB_TAG=nightly tox -e api_func_v4
+   GITLAB_TAG=12.8.0-ce.0 tox -e api_func_v4
+
+   # run tests against the latest gitlab EE image
+   GITLAB_IMAGE=gitlab/gitlab-ee tox -e api_func_v4
+
+A freshly configured gitlab container will be available at
+http://localhost:8080 (login ``root`` / password ``5iveL!fe``). A configuration
+for python-gitlab will be written in ``/tmp/python-gitlab.cfg``.
+
+To cleanup the environment delete the container:
+
+.. code-block:: bash
+
+   docker rm -f gitlab-test
+   docker rm -f gitlab-runner-test
+
+Rerunning failed CI workflows
+-----------------------------
+
+* Ask the maintainers to add the ``ok-to-test`` label on the PR
+* Post a comment in the PR
+   ``/rerun-all`` - rerun all failed workflows
+
+   ``/rerun-workflow <workflow name>`` - rerun a specific failed workflow
+
+The functionality is provided by ``rerun-action <https://github.com/marketplace/actions/rerun-actions>``
+
+Releases
+--------
+
+A release is automatically published once a month on the 28th if any commits merged
+to the main branch contain commit message types that signal a semantic version bump
+(``fix``, ``feat``, ``BREAKING CHANGE:``).
+
+Additionally, the release workflow can be run manually by maintainers to publish urgent
+fixes, either on GitHub or using the ``gh`` CLI with ``gh workflow run release.yml``.
+
+**Note:** As a maintainer, this means you should carefully review commit messages
+used by contributors in their pull requests. If scopes such as ``fix`` and ``feat``
+are applied to trivial commits not relevant to end users, it's best to squash their
+pull requests and summarize the addition in a single conventional commit.
+This avoids triggering incorrect version bumps and releases without functional changes.
+
+The release workflow uses `python-semantic-release
+<https://python-semantic-release.readthedocs.io>`_ and does the following:
+
+* Bumps the version in ``_version.py`` and adds an entry in ``CHANGELOG.md``,
+* Commits and tags the changes, then pushes to the main branch as the ``github-actions`` user,
+* Creates a release from the tag and adds the changelog entry to the release notes,
+* Uploads the package as assets to the GitHub release,
+* Uploads the package to PyPI using ``PYPI_TOKEN`` (configured as a secret).
diff --git a/ChangeLog b/ChangeLog
deleted file mode 100644
index e769d163f..000000000
--- a/ChangeLog
+++ /dev/null
@@ -1,339 +0,0 @@
-Version 0.18
-
- * Fix JIRA service editing for GitLab 8.14+
- * Add jira_issue_transition_id to the JIRA service optional fields
- * Added support for Snippets (new API in Gitlab 8.15)
- * [docs] update pagination section
- * [docs] artifacts example: open file in wb mode
- * [CLI] ignore empty arguments
- * [CLI] Fix wrong use of arguments
- * [docs] Add doc for snippets
- * Fix duplicated data in API docs
- * Update known attributes for projects
- * sudo: always use strings
-
-Version 0.17
-
- * README: add badges for pypi and RTD
- * Fix ProjectBuild.play (raised error on success)
- * Pass kwargs to the object factory
- * Add .tox to ignore to respect default tox settings
- * Convert response list to single data source for iid requests
- * Add support for boards API
- * Add support for Gitlab.version()
- * Add support for broadcast messages API
- * Add support for the notification settings API
- * Don't overwrite attributes returned by the server
- * Fix bug when retrieving changes for merge request
- * Feature: enable / disable the deploy key in a project
- * Docs: add a note for python 3.5 for file content update
- * ProjectHook: support the token attribute
- * Rework the API documentation
- * Fix docstring for http_{username,password}
- * Build managers on demand on GitlabObject's
- * API docs: add managers doc in GitlabObject's
- * Sphinx ext: factorize the build methods
- * Implement __repr__ for gitlab objects
- * Add a 'report a bug' link on doc
- * Remove deprecated methods
- * Implement merge requests diff support
- * Make the manager objects creation more dynamic
- * Add support for templates API
- * Add attr 'created_at' to ProjectIssueNote
- * Add attr 'updated_at' to ProjectIssue
- * CLI: add support for project all --all
- * Add support for triggering a new build
- * Rework requests arguments (support latest requests release)
- * Fix `should_remove_source_branch`
-
-Version 0.16
-
- * Add the ability to fork to a specific namespace
- * JIRA service - add api_url to optional attributes
- * Fix bug: Missing coma concatenates array values
- * docs: branch protection notes
- * Create a project in a group
- * Add only_allow_merge_if_build_succeeds option to project objects
- * Add support for --all in CLI
- * Fix examples for file modification
- * Use the plural merge_requests URL everywhere
- * Rework travis and tox setup
- * Workaround gitlab setup failure in tests
- * Add ProjectBuild.erase()
- * Implement ProjectBuild.play()
-
-Version 0.15.1
-
- * docs: improve the pagination section
- * Fix and test pagination
- * 'path' is an existing gitlab attr, don't use it as method argument
-
-Version 0.15
-
- * Add a basic HTTP debug method
- * Run more tests in travis
- * Fix fork creation documentation
- * Add more API examples in docs
- * Update the ApplicationSettings attributes
- * Implement the todo API
- * Add sidekiq metrics support
- * Move the constants at the gitlab root level
- * Remove methods marked as deprecated 7 months ago
- * Refactor the Gitlab class
- * Remove _get_list_or_object() and its tests
- * Fix canGet attribute (typo)
- * Remove unused ProjectTagReleaseManager class
- * Add support for project services API
- * Add support for project pipelines
- * Add support for access requests
- * Add support for project deployments
-
-Version 0.14
-
- * Remove 'next_url' from kwargs before passing it to the cls constructor.
- * List projects under group
- * Add support for subscribe and unsubscribe in issues
- * Project issue: doc and CLI for (un)subscribe
- * Added support for HTTP basic authentication
- * Add support for build artifacts and trace
- * --title is a required argument for ProjectMilestone
- * Commit status: add optional context url
- * Commit status: optional get attrs
- * Add support for commit comments
- * Issues: add optional listing parameters
- * Issues: add missing optional listing parameters
- * Project issue: proper update attributes
- * Add support for project-issue move
- * Update ProjectLabel attributes
- * Milestone: optional listing attrs
- * Add support for namespaces
- * Add support for label (un)subscribe
- * MR: add (un)subscribe support
- * Add `note_events` to project hooks attributes
- * Add code examples for a bunch of resources
- * Implement user emails support
- * Project: add VISIBILITY_* constants
- * Fix the Project.archive call
- * Implement archive/unarchive for a projet
- * Update ProjectSnippet attributes
- * Fix ProjectMember update
- * Implement sharing project with a group
- * Implement CLI for project archive/unarchive/share
- * Implement runners global API
- * Gitlab: add managers for build-related resources
- * Implement ProjectBuild.keep_artifacts
- * Allow to stream the downloads when appropriate
- * Groups can be updated
- * Replace Snippet.Content() with a new content() method
- * CLI: refactor _die()
- * Improve commit statuses and comments
- * Add support from listing group issues
- * Added a new project attribute to enable the container registry.
- * Add a contributing section in README
- * Add support for global deploy key listing
- * Add support for project environments
- * MR: get list of changes and commits
- * Fix the listing of some resources
- * MR: fix updates
- * Handle empty messages from server in exceptions
- * MR (un)subscribe: don't fail if state doesn't change
- * MR merge(): update the object
-
-Version 0.13
-
- * Add support for MergeRequest validation
- * MR: add support for cancel_merge_when_build_succeeds
- * MR: add support for closes_issues
- * Add "external" parameter for users
- * Add deletion support for issues and MR
- * Add missing group creation parameters
- * Add a Session instance for all HTTP requests
- * Enable updates on ProjectIssueNotes
- * Add support for Project raw_blob
- * Implement project compare
- * Implement project contributors
- * Drop the next_url attribute when listing
- * Remove unnecessary canUpdate property from ProjectIssuesNote
- * Add new optional attributes for projects
- * Enable deprecation warnings for gitlab only
- * Rework merge requests update
- * Rework the Gitlab.delete method
- * ProjectFile: file_path is required for deletion
- * Rename some methods to better match the API URLs
- * Deprecate the file_* methods in favor of the files manager
- * Implement star/unstar for projects
- * Implement list/get licenses
- * Manage optional parameters for list() and get()
-
-Version 0.12.2
-
- * Add new `ProjectHook` attributes
- * Add support for user block/unblock
- * Fix GitlabObject creation in _custom_list
- * Add support for more CLI subcommands
- * Add some unit tests for CLI
- * Add a coverage tox env
- * Define GitlabObject.as_dict() to dump object as a dict
- * Define GitlabObject.__eq__() and __ne__() equivalence methods
- * Define UserManager.search() to search for users
- * Define UserManager.get_by_username() to get a user by username
- * Implement "user search" CLI
- * Improve the doc for UserManager
- * CLI: implement user get-by-username
- * Re-implement _custom_list in the Gitlab class
- * Fix the 'invalid syntax' error on Python 3.2
- * Gitlab.update(): use the proper attributes if defined
-
-Version 0.12.1
-
- * Fix a broken upload to pypi
-
-Version 0.12
-
- * Improve documentation
- * Improve unit tests
- * Improve test scripts
- * Skip BaseManager attributes when encoding to JSON
- * Fix the json() method for python 3
- * Add Travis CI support
- * Add a decode method for ProjectFile
- * Make connection exceptions more explicit
- * Fix ProjectLabel get and delete
- * Implement ProjectMilestone.issues()
- * ProjectTag supports deletion
- * Implement setting release info on a tag
- * Implement project triggers support
- * Implement project variables support
- * Add support for application settings
- * Fix the 'password' requirement for User creation
- * Add sudo support
- * Fix project update
- * Fix Project.tree()
- * Add support for project builds
-
-Version 0.11.1
-
- * Fix discovery of parents object attrs for managers
- * Support setting commit status
- * Support deletion without getting the object first
- * Improve the documentation
-
-Version 0.11
-
- * functional_tests.sh: support python 2 and 3
- * Add a get method for GitlabObject
- * CLI: Add the -g short option for --gitlab
- * Provide a create method for GitlabObject's
- * Rename the _created attribute _from_api
- * More unit tests
- * CLI: fix error when arguments are missing (python 3)
- * Remove deprecated methods
- * Implement managers to get access to resources
- * Documentation improvements
- * Add fork project support
- * Deprecate the "old" Gitlab methods
- * Add support for groups search
-
-Version 0.10
-
- * Implement pagination for list() (#63)
- * Fix url when fetching a single MergeRequest
- * Add support to update MergeRequestNotes
- * API: Provide a Gitlab.from_config method
- * setup.py: require requests>=1 (#69)
- * Fix deletion of object not using 'id' as ID (#68)
- * Fix GET/POST for project files
- * Make 'confirm' an optional attribute for user creation
- * Python 3 compatibility fixes
- * Add support for group members update (#73)
-
-Version 0.9.2
-
- * CLI: fix the update and delete subcommands (#62)
-
-Version 0.9.1
-
- * Fix the setup.py script
-
-Version 0.9
-
- * Implement argparse libray for parsing argument on CLI
- * Provide unit tests and (a few) functional tests
- * Provide PEP8 tests
- * Use tox to run the tests
- * CLI: provide a --config-file option
- * Turn the gitlab module into a proper package
- * Allow projects to be updated
- * Use more pythonic names for some methods
- * Deprecate some Gitlab object methods:
-   - raw* methods should never have been exposed; replace them with _raw_*
-     methods
-   - setCredentials and setToken are replaced with set_credentials and
-     set_token
- * Sphinx: don't hardcode the version in conf.py
-
-Version 0.8
-
- * Better python 2.6 and python 3 support
- * Timeout support in HTTP requests
- * Gitlab.get() raised GitlabListError instead of GitlabGetError
- * Support api-objects which don't have id in api response
- * Add ProjectLabel and ProjectFile classes
- * Moved url attributes to separate list
- * Added list for delete attributes
-
-Version 0.7
-
- * Fix license classifier in setup.py
- * Fix encoding error when printing to redirected output
- * Fix encoding error when updating with redirected output
- * Add support for UserKey listing and deletion
- * Add support for branches creation and deletion
- * Support state_event in ProjectMilestone (#30)
- * Support namespace/name for project id (#28)
- * Fix handling of boolean values (#22)
-
-Version 0.6
-
- * IDs can be unicode (#15)
- * ProjectMember: constructor should not create a User object
- * Add support for extra parameters when listing all projects (#12)
- * Projects listing: explicitly define arguments for pagination
-
-Version 0.5
-
- * Add SSH key for user
- * Fix comments
- * Add support for project events
- * Support creation of projects for users
- * Project: add methods for create/update/delete files
- * Support projects listing: search, all, owned
- * System hooks can't be updated
- * Project.archive(): download tarball of the project
- * Define new optional attributes for user creation
- * Provide constants for access permissions in groups
-
-Version 0.4
-
- * Fix strings encoding (Closes #6)
- * Allow to get a project commit (GitLab 6.1)
- * ProjectMergeRequest: fix Note() method
- * Gitlab 6.1 methods: diff, blob (commit), tree, blob (project)
- * Add support for Gitlab 6.1 group members
-
-Version 0.3
-
- * Use PRIVATE-TOKEN header for passing the auth token
- * provide a AUTHORS file
- * cli: support ssl_verify config option
- * Add ssl_verify option to Gitlab object. Defauls to True
- * Correct url for merge requests API.
-
-Version 0.2
-
- * provide a pip requirements.txt
- * drop some debug statements
-
-Version 0.1
-
- * Initial release
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 000000000..c66b642fd
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,19 @@
+ARG PYTHON_FLAVOR=alpine
+FROM python:3.12-${PYTHON_FLAVOR} AS build
+
+WORKDIR /opt/python-gitlab
+COPY . .
+RUN pip install --no-cache-dir build && python -m build --wheel
+
+FROM python:3.12-${PYTHON_FLAVOR}
+
+LABEL org.opencontainers.image.source="https://github.com/python-gitlab/python-gitlab"
+
+WORKDIR /opt/python-gitlab
+COPY --from=build /opt/python-gitlab/dist dist/
+RUN pip install --no-cache-dir PyYaml
+RUN pip install --no-cache-dir $(find dist -name *.whl) && \
+    rm -rf dist/
+
+ENTRYPOINT ["gitlab"]
+CMD ["--version"]
diff --git a/MANIFEST.in b/MANIFEST.in
index e677be789..ba34af210 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,4 +1,4 @@
-include COPYING AUTHORS ChangeLog requirements.txt test-requirements.txt rtd-requirements.txt
-include tox.ini .testr.conf .travis.yml
-recursive-include tools *
-recursive-include docs *j2 *.py *.rst api/*.rst Makefile make.bat
+include COPYING AUTHORS CHANGELOG.md requirements*.txt
+include tox.ini gitlab/py.typed
+recursive-include tests *
+recursive-include docs *j2 *.js *.md *.py *.rst api/*.rst Makefile make.bat
diff --git a/README.rst b/README.rst
index 1b0136d84..101add1eb 100644
--- a/README.rst
+++ b/README.rst
@@ -1,5 +1,8 @@
-.. image:: https://travis-ci.org/gpocentek/python-gitlab.svg?branch=master
-   :target: https://travis-ci.org/gpocentek/python-gitlab
+python-gitlab
+=============
+
+.. image:: https://github.com/python-gitlab/python-gitlab/workflows/Test/badge.svg
+   :target: https://github.com/python-gitlab/python-gitlab/actions
 
 .. image:: https://badge.fury.io/py/python-gitlab.svg
    :target: https://badge.fury.io/py/python-gitlab
@@ -7,108 +10,181 @@
 .. image:: https://readthedocs.org/projects/python-gitlab/badge/?version=latest
    :target: https://python-gitlab.readthedocs.org/en/latest/?badge=latest
 
-Python GitLab
-=============
+.. image:: https://codecov.io/github/python-gitlab/python-gitlab/coverage.svg?branch=main
+    :target: https://codecov.io/github/python-gitlab/python-gitlab?branch=main
 
-``python-gitlab`` is a Python package providing access to the GitLab server API.
+.. image:: https://img.shields.io/pypi/pyversions/python-gitlab.svg
+   :target: https://pypi.python.org/pypi/python-gitlab
 
-It supports the v3 api of GitLab, and provides a CLI tool (``gitlab``).
+.. image:: https://img.shields.io/gitter/room/python-gitlab/Lobby.svg
+   :target: https://gitter.im/python-gitlab/Lobby
 
-Installation
-============
+.. image:: https://img.shields.io/badge/code%20style-black-000000.svg
+    :target: https://github.com/python/black
+
+.. image:: https://img.shields.io/github/license/python-gitlab/python-gitlab
+   :target: https://github.com/python-gitlab/python-gitlab/blob/main/COPYING
+
+``python-gitlab`` is a Python package providing access to the GitLab APIs.
+
+It includes a client for GitLab's v4 REST API, synchronous and asynchronous GraphQL API
+clients, as well as a CLI tool (``gitlab``) wrapping REST API endpoints.
 
-Requirements
+.. _features:
+
+Features
+--------
+
+``python-gitlab`` enables you to:
+
+* write Pythonic code to manage your GitLab resources.
+* pass arbitrary parameters to the GitLab API. Simply follow GitLab's docs
+  on what parameters are available.
+* use a synchronous or asynchronous client when using the GraphQL API.
+* access arbitrary endpoints as soon as they are available on GitLab, by using
+  lower-level API methods.
+* use persistent requests sessions for authentication, proxy and certificate handling.
+* handle smart retries on network and server errors, with rate-limit handling.
+* flexible handling of paginated responses, including lazy iterators.
+* automatically URL-encode paths and parameters where needed.
+* automatically convert some complex data structures to API attribute types
+* merge configuration from config files, environment variables and arguments.
+
+Installation
 ------------
 
-python-gitlab depends on:
+As of 5.0.0, ``python-gitlab`` is compatible with Python 3.9+.
 
-* `python-requests <http://docs.python-requests.org/en/latest/>`_
-* `six <https://pythonhosted.org/six/>`_
+Use ``pip`` to install the latest stable version of ``python-gitlab``:
 
-Install with pip
-----------------
+.. code-block:: console
+
+   $ pip install --upgrade python-gitlab
+
+The current development version is available on both `GitHub.com
+<https://github.com/python-gitlab/python-gitlab>`__ and `GitLab.com
+<https://gitlab.com/python-gitlab/python-gitlab>`__, and can be
+installed directly from the git repository:
 
 .. code-block:: console
 
-   pip install python-gitlab
+   $ pip install git+https://github.com/python-gitlab/python-gitlab.git
 
-Bug reports
-===========
+From GitLab:
 
-Please report bugs and feature requests at
-https://github.com/gpocentek/python-gitlab/issues.
+.. code-block:: console
 
+   $ pip install git+https://gitlab.com/python-gitlab/python-gitlab.git
 
-Documentation
-=============
+Using the docker images
+-----------------------
 
-The full documentation for CLI and API is available on `readthedocs
-<http://python-gitlab.readthedocs.org/en/stable/>`_.
+``python-gitlab`` provides Docker images in two flavors, based on the Alpine and Debian slim
+python `base images <https://hub.docker.com/_/python>`__. The default tag is ``alpine``,
+but you can explicitly use the alias (see below).
 
+The alpine image is smaller, but you may want to use the Debian-based slim tag (currently 
+based on ``-slim-bullseye``) if you are running into issues or need a more complete environment
+with a bash shell, such as in CI jobs.
 
-Contributing
-============
+The images are published on the GitLab registry, for example:
 
-You can contribute to the project in multiple ways:
+* ``registry.gitlab.com/python-gitlab/python-gitlab:latest`` (latest, alpine alias)
+* ``registry.gitlab.com/python-gitlab/python-gitlab:alpine`` (latest alpine)
+* ``registry.gitlab.com/python-gitlab/python-gitlab:slim-bullseye`` (latest slim-bullseye)
+* ``registry.gitlab.com/python-gitlab/python-gitlab:v3.2.0`` (alpine alias)
+* ``registry.gitlab.com/python-gitlab/python-gitlab:v3.2.0-alpine``
+* ``registry.gitlab.com/python-gitlab/python-gitlab:v3.2.0-slim-bullseye``
 
-* Write documentation
-* Implement features
-* Fix bugs
-* Add unit and functional tests
-* Everything else you can think of
+You can run the Docker image directly from the GitLab registry:
 
-Provide your patches as github pull requests. Thanks!
+.. code-block:: console
 
-Running unit tests
-------------------
+   $ docker run -it --rm registry.gitlab.com/python-gitlab/python-gitlab:latest <command> ...
 
-Before submitting a pull request make sure that the tests still succeed with
-your change. Unit tests will run using the travis service and passing tests are
-mandatory.
+For example, to get a project on GitLab.com (without authentication):
 
-You need to install ``tox`` to run unit tests and documentation builds:
+.. code-block:: console
 
-.. code-block:: bash
+   $ docker run -it --rm registry.gitlab.com/python-gitlab/python-gitlab:latest project get --id gitlab-org/gitlab
 
-   # run the unit tests for python 2/3, and the pep8 tests:
-   tox
+You can also mount your own config file:
 
-   # run tests in one environment only:
-   tox -epy35
+.. code-block:: console
+
+   $ docker run -it --rm -v /path/to/python-gitlab.cfg:/etc/python-gitlab.cfg registry.gitlab.com/python-gitlab/python-gitlab:latest <command> ...
+
+Usage inside GitLab CI
+~~~~~~~~~~~~~~~~~~~~~~
+
+If you want to use the Docker image directly inside your GitLab CI as an ``image``, you will need to override
+the ``entrypoint``, `as noted in the official GitLab documentation <https://docs.gitlab.com/ee/ci/docker/using_docker_images.html#override-the-entrypoint-of-an-image>`__:
+
+.. code-block:: yaml
+
+   Job Name:
+      image:
+         name: registry.gitlab.com/python-gitlab/python-gitlab:latest
+         entrypoint: [""]
+      before_script:
+         gitlab --version
+      script:
+         gitlab <command>
+
+Building the image
+~~~~~~~~~~~~~~~~~~
+
+To build your own image from this repository, run:
+
+.. code-block:: console
+
+   $ docker build -t python-gitlab:latest .
+
+Run your own image:
 
-   # build the documentation, the result will be generated in
-   # build/sphinx/html/
-   tox -edocs
+.. code-block:: console
+
+   $ docker run -it --rm python-gitlab:latest <command> ...
+
+Build a Debian slim-based image:
 
-Running integration tests
--------------------------
+.. code-block:: console
+
+   $ docker build -t python-gitlab:latest --build-arg PYTHON_FLAVOR=slim-bullseye .
 
-Two scripts run tests against a running gitlab instance, using a docker
-container. You need to have docker installed on the test machine, and your user
-must have the correct permissions to talk to the docker daemon.
+Bug reports
+-----------
 
-To run these tests:
+Please report bugs and feature requests at
+https://github.com/python-gitlab/python-gitlab/issues.
 
-.. code-block:: bash
+Gitter Community Chat
+---------------------
 
-   # run the CLI tests:
-   ./tools/functional_tests.sh
+We have a `gitter <https://gitter.im/python-gitlab/Lobby>`_ community chat
+available at https://gitter.im/python-gitlab/Lobby, which you can also
+directly access via the Open Chat button below.
 
-   # run the python API tests:
-   ./tools/py_functional_tests.sh
+If you have a simple question, the community might be able to help already,
+without you opening an issue. If you regularly use python-gitlab, we also
+encourage you to join and participate. You might discover new ideas and
+use cases yourself!
 
-You can also build a test environment using the following command:
+Documentation
+-------------
 
-.. code-block:: bash
+The full documentation for CLI and API is available on `readthedocs
+<http://python-gitlab.readthedocs.org/en/stable/>`_.
 
-   ./tools/build_test_env.sh
+Build the docs
+~~~~~~~~~~~~~~
 
-A freshly configured gitlab container will be available at
-http://localhost:8080 (login ``root`` / password ``5iveL!fe``). A configuration
-for python-gitlab will be written in ``/tmp/python-gitlab.cfg``.
+We use ``tox`` to manage our environment and build the documentation::
 
-To cleanup the environment delete the container:
+    pip install tox
+    tox -e docs
 
-.. code-block:: bash
+Contributing
+------------
 
-   docker rm -f gitlab-test
+For guidelines for contributing to ``python-gitlab``, refer to `CONTRIBUTING.rst <https://github.com/python-gitlab/python-gitlab/blob/main/CONTRIBUTING.rst>`_.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 000000000..ffdc9ab76
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,17 @@
+# Security Policy
+
+## Reporting a Vulnerability
+
+python-gitlab is a thin wrapper and you should generally mostly ensure your transitive dependencies are kept up-to-date.
+
+However, if you find an issue that may be security relevant, please
+[Report a security vulnerability](https://github.com/python-gitlab/python-gitlab/security/advisories/new)
+on GitHub.
+
+Alternatively, if you cannot report vulnerabilities on GitHub,
+you can email the currently active maintainers listed in [AUTHORS](https://github.com/python-gitlab/python-gitlab/blob/main/AUTHORS).
+
+## Supported Versions
+
+We will typically apply fixes for the current major version. As the package is distributed on
+PyPI and GitLab's container registry, users are encouraged to always update to the latest version.
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 000000000..e11e8019b
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,18 @@
+codecov:
+  notify:
+    after_n_builds: 3
+  require_ci_to_pass: yes
+
+coverage:
+  precision: 2
+  round: down
+  range: "70...100"
+
+comment:
+  after_n_builds: 3 # coverage, api_func_v4, py_func_cli
+  layout: "diff,flags,files"
+  behavior: default
+  require_changes: yes
+
+github_checks:
+  annotations: true
diff --git a/gitlab/tests/__init__.py b/docs/__init__.py
similarity index 100%
rename from gitlab/tests/__init__.py
rename to docs/__init__.py
diff --git a/docs/_static/js/gitter.js b/docs/_static/js/gitter.js
new file mode 100644
index 000000000..1340cb483
--- /dev/null
+++ b/docs/_static/js/gitter.js
@@ -0,0 +1,3 @@
+((window.gitter = {}).chat = {}).options = {
+  room: 'python-gitlab/Lobby'
+};
diff --git a/docs/_templates/breadcrumbs.html b/docs/_templates/breadcrumbs.html
deleted file mode 100644
index 35c1ed0d5..000000000
--- a/docs/_templates/breadcrumbs.html
+++ /dev/null
@@ -1,24 +0,0 @@
-{# Support for Sphinx 1.3+ page_source_suffix, but don't break old builds. #}
-
-{% if page_source_suffix %} 
-{% set suffix = page_source_suffix %}
-{% else %}
-{% set suffix = source_suffix %}
-{% endif %}
-
-<div role="navigation" aria-label="breadcrumbs navigation">
-  <ul class="wy-breadcrumbs">
-    <li><a href="{{ pathto(master_doc) }}">Docs</a> &raquo;</li>
-      {% for doc in parents %}
-          <li><a href="{{ doc.link|e }}">{{ doc.title }}</a> &raquo;</li>
-      {% endfor %}
-    <li>{{ title }}</li>
-      <li class="wy-breadcrumbs-aside">
-        {% if pagename != "search" %}
-            <a href="https://github.com/gpocentek/python-gitlab/blob/{{ github_version }}{{ conf_py_path }}{{ pagename }}{{ suffix }}" class="fa fa-github"> Edit on GitHub</a>
-            | <a href="https://github.com/gpocentek/python-gitlab/issues/new?title=Documentation+bug&body=%0A%0A------%0AIn+page:+{{ pagename }}{{ suffix }}">Report a bug</a>
-        {% endif %}
-      </li>
-  </ul>
-  <hr/>
-</div>
diff --git a/docs/api-levels.rst b/docs/api-levels.rst
new file mode 100644
index 000000000..2fdc42dbd
--- /dev/null
+++ b/docs/api-levels.rst
@@ -0,0 +1,105 @@
+################
+Lower-level APIs
+################
+
+``python-gitlab``'s API levels provide different degrees of convenience, control and stability.
+
+Main interface - ``Gitlab``, managers and objects
+=================================================
+
+As shown in previous sections and examples, the high-level API interface wraps GitLab's API
+endpoints and makes them available from the ``Gitlab`` instance via managers that create
+objects you can manipulate.
+
+This is what most users will want to use, as it covers most of GitLab's API endpoints, and
+allows you to write idiomatic Python code when interacting with the API.
+
+Lower-level API - HTTP methods
+==============================
+
+.. danger::
+
+   At this point, python-gitlab will no longer take care of URL-encoding and other transformations
+   needed to correctly pass API parameter types. You have to construct these correctly yourself.
+
+   However, you still benefit from many of the client's :ref:`features` such as authentication,
+   requests and retry handling.
+
+.. important::
+
+   If you've found yourself at this section because of an endpoint not yet implemented in
+   the library - please consider opening a pull request implementing the resource or at
+   least filing an issue so we can track progress.
+
+   High-quality pull requests for standard endpoints that pass CI and include unit tests and
+   documentation are easy to review, and can land quickly with monthly releases. If you ask,
+   we can also trigger a new release, so you and everyone benefits from the contribution right away!
+
+Managers and objects call specific HTTP methods to fetch or send data to the server. These methods
+can be invoked directly to access endpoints not currently implemented by the client. This essentially
+gives you some level of usability for any endpoint the moment it is available on your GitLab instance.
+
+These methods can be accessed directly via the ``Gitlab`` instance (e.g. ``gl.http_get()``), or via an
+object's manager (e.g. ``project.manager.gitlab.http_get()``), if the ``Gitlab`` instance is not available
+in the current context.
+
+For example, if you'd like to access GitLab's `undocumented latest pipeline endpoint
+<https://gitlab.com/gitlab-org/gitlab/-/blob/5e2a61166d2a033d3fd1eb4c09d896ed19a57e60/lib/api/ci/pipelines.rb#L97>`__,
+you can do so by calling ``http_get()`` with the path to the endpoint:
+
+.. code-block:: python
+
+    >>> gl = gitlab.Gitlab(private_token=private_token)
+    >>>
+    >>> pipeline = gl.http_get("/projects/gitlab-org%2Fgitlab/pipelines/latest")
+    >>> pipeline["id"]
+    449070256
+
+The available methods are:
+
+* ``http_get()``
+* ``http_post()``
+* ``http_put()``
+* ``http_patch()``
+* ``http_delete()``
+* ``http_list()`` (a wrapper around ``http_get`` handling pagination, including with lazy generators)
+* ``http_head()`` (only returns the header dictionary)
+
+Lower-lower-level API - HTTP requests
+=====================================
+
+.. important::
+
+    This is mostly intended for internal use in python-gitlab and may have a less stable interface than
+    higher-level APIs. To lessen the chances of a change to the interface impacting your code, we
+    recommend using keyword arguments when calling the interfaces.
+
+At the lowest level, HTTP methods call ``http_request()``, which performs the actual request and takes
+care of details such as timeouts, retries, and handling rate-limits.
+
+This method can be invoked directly to or customize this behavior for a single request, or to call custom
+HTTP methods not currently implemented in the library - while still making use of all of the client's
+options and authentication methods.
+
+For example, if for whatever reason you want to fetch allowed methods for an endpoint at runtime:
+
+.. code-block:: python
+
+    >>> gl = gitlab.Gitlab(private_token=private_token)
+    >>>
+    >>> response = gl.http_request(verb="OPTIONS", path="/projects")
+    >>> response.headers["Allow"]
+    'OPTIONS, GET, POST, HEAD'
+
+Or get the total number of a user's events with a customized HEAD request:
+
+.. code-block:: python
+
+    >>> response = gl.http_request(
+            verb="HEAD",
+            path="/events",
+            query_params={"sudo": "some-user"},
+            timeout=10
+        )
+    >>> response.headers["X-Total"]
+    '123'
diff --git a/docs/api-objects.rst b/docs/api-objects.rst
index 010e9d650..7218518b1 100644
--- a/docs/api-objects.rst
+++ b/docs/api-objects.rst
@@ -1,31 +1,77 @@
-########################
-API objects manipulation
-########################
+############
+API examples
+############
 
 .. toctree::
    :maxdepth: 1
 
    gl_objects/access_requests
+   gl_objects/appearance
+   gl_objects/applications
+   gl_objects/emojis
+   gl_objects/badges
    gl_objects/branches
+   gl_objects/bulk_imports
    gl_objects/messages
-   gl_objects/builds
+   gl_objects/ci_lint
+   gl_objects/cluster_agents
    gl_objects/commits
    gl_objects/deploy_keys
+   gl_objects/deploy_tokens
    gl_objects/deployments
+   gl_objects/discussions
+   gl_objects/draft_notes
    gl_objects/environments
+   gl_objects/events
+   gl_objects/epics
+   gl_objects/features
+   gl_objects/geo_nodes
    gl_objects/groups
+   gl_objects/group_access_tokens
+   gl_objects/invitations
    gl_objects/issues
+   gl_objects/iterations
+   gl_objects/job_token_scope
+   gl_objects/keys
+   gl_objects/boards
    gl_objects/labels
    gl_objects/notifications
-   gl_objects/mrs
-   gl_objects/namespaces
+   gl_objects/member_roles.rst
+   gl_objects/merge_trains
+   gl_objects/merge_requests
+   gl_objects/merge_request_approvals.rst
    gl_objects/milestones
+   gl_objects/namespaces
+   gl_objects/notes
+   gl_objects/packages
+   gl_objects/pagesdomains
+   gl_objects/personal_access_tokens
+   gl_objects/pipelines_and_jobs
    gl_objects/projects
+   gl_objects/project_access_tokens
+   gl_objects/protected_branches
+   gl_objects/protected_container_repositories
+   gl_objects/protected_environments
+   gl_objects/protected_packages
+   gl_objects/pull_mirror
+   gl_objects/releases
    gl_objects/runners
+   gl_objects/remote_mirrors
+   gl_objects/repositories
+   gl_objects/repository_tags
+   gl_objects/resource_groups
+   gl_objects/search
+   gl_objects/secure_files
    gl_objects/settings
    gl_objects/snippets
+   gl_objects/statistics
+   gl_objects/status_checks
    gl_objects/system_hooks
    gl_objects/templates
    gl_objects/todos
+   gl_objects/topics
    gl_objects/users
+   gl_objects/variables
    gl_objects/sidekiq
+   gl_objects/wikis
+   gl_objects/clusters
diff --git a/docs/api-usage-advanced.rst b/docs/api-usage-advanced.rst
new file mode 100644
index 000000000..d6514c7b3
--- /dev/null
+++ b/docs/api-usage-advanced.rst
@@ -0,0 +1,230 @@
+##############
+Advanced usage
+##############
+
+Using a custom session
+----------------------
+
+python-gitlab relies on ``requests.Session`` objects to perform all the
+HTTP requests to the GitLab servers.
+
+You can provide a custom session to create ``gitlab.Gitlab`` objects:
+
+.. code-block:: python
+
+   import gitlab
+   import requests
+
+   session = requests.Session()
+   gl = gitlab.Gitlab(session=session)
+
+   # or when instantiating from configuration files
+   gl = gitlab.Gitlab.from_config('somewhere', ['/tmp/gl.cfg'], session=session)
+
+Reference:
+https://requests.readthedocs.io/en/latest/user/advanced/#session-objects
+
+Context manager
+---------------
+
+You can use ``Gitlab`` objects as context managers. This makes sure that the
+``requests.Session`` object associated with a ``Gitlab`` instance is always
+properly closed when you exit a ``with`` block:
+
+.. code-block:: python
+
+   with gitlab.Gitlab(host, token) as gl:
+       gl.statistics.get()
+
+.. warning::
+
+   The context manager will also close the custom ``Session`` object you might
+   have used to build the ``Gitlab`` instance.
+
+netrc authentication
+--------------------
+
+python-gitlab reads credentials from ``.netrc`` files via the ``requests`` backend
+only if you do not provide any other type of authentication yourself.
+
+If you'd like to disable reading netrc files altogether, you can follow `Using a custom session`_
+and explicitly set ``trust_env=False`` as described in the ``requests`` documentation.
+
+.. code-block:: python
+
+   import gitlab
+   import requests
+
+   session = requests.Session(trust_env=False)
+   gl = gitlab.Gitlab(session=session)
+
+Reference:
+https://requests.readthedocs.io/en/latest/user/authentication/#netrc-authentication
+
+Proxy configuration
+-------------------
+
+python-gitlab accepts the standard ``http_proxy``, ``https_proxy`` and ``no_proxy``
+environment variables via the ``requests`` backend. Uppercase variables are also supported.
+
+For more granular control, you can also explicitly set proxies by `Using a custom session`_
+as described in the ``requests`` documentation.
+
+Reference:
+https://requests.readthedocs.io/en/latest/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://requests.readthedocs.io/en/latest/user/advanced/#ssl-cert-verification
+
+Client side certificate
+-----------------------
+
+The following sample illustrates how to use a client-side certificate:
+
+.. code-block:: python
+
+   import gitlab
+   import requests
+
+   session = requests.Session()
+   session.cert = ('/path/to/client.cert', '/path/to/client.key')
+   gl = gitlab.Gitlab(url, token, api_version=4, session=session)
+
+Reference:
+https://requests.readthedocs.io/en/latest/user/advanced/#client-side-certificates
+
+Rate limits
+-----------
+
+python-gitlab obeys the rate limit of the GitLab server by default.  On
+receiving a 429 response (Too Many Requests), python-gitlab sleeps for the
+amount of time in the Retry-After header that GitLab sends back.  If GitLab
+does not return a response with the Retry-After header, python-gitlab will
+perform an exponential backoff.
+
+If you don't want to wait, you can disable the rate-limiting feature, by
+supplying the ``obey_rate_limit`` argument.
+
+.. code-block:: python
+
+   import gitlab
+   import requests
+
+   gl = gitlab.Gitlab(url, token, api_version=4)
+   gl.projects.list(get_all=True, obey_rate_limit=False)
+
+If you do not disable the rate-limiting feature, you can supply a custom value
+for ``max_retries``; by default, this is set to 10. To retry without bound when
+throttled, you can set this parameter to -1. This parameter is ignored if
+``obey_rate_limit`` is set to ``False``.
+
+.. code-block:: python
+
+   import gitlab
+   import requests
+
+   gl = gitlab.Gitlab(url, token, api_version=4)
+   gl.projects.list(get_all=True, max_retries=12)
+
+.. warning::
+
+   You will get an Exception, if you then go over the rate limit of your GitLab instance.
+
+Transient errors
+----------------
+
+GitLab server can sometimes return a transient HTTP error.
+python-gitlab can automatically retry in such case, when
+``retry_transient_errors`` argument is set to ``True``.  When enabled,
+HTTP error codes 500 (Internal Server Error), 502 (502 Bad Gateway),
+503 (Service Unavailable), 504 (Gateway Timeout), and Cloudflare
+errors (520-530) are retried.
+
+Additionally, HTTP error code 409 (Conflict) is retried if the reason
+is a
+`Resource lock <https://gitlab.com/gitlab-org/gitlab/-/blob/443c12cf3b238385db728f03b2cdbb4f17c70292/lib/api/api.rb#L111>`__.
+
+It will retry until reaching the ``max_retries``
+value. By default, ``retry_transient_errors`` is set to ``False`` and an
+exception is raised for these errors.
+
+.. code-block:: python
+
+   import gitlab
+   import requests
+
+   gl = gitlab.Gitlab(url, token, api_version=4)
+   gl.projects.list(get_all=True, retry_transient_errors=True)
+
+The default ``retry_transient_errors`` can also be set on the ``Gitlab`` object
+and overridden by individual API calls.
+
+.. code-block:: python
+
+   import gitlab
+   import requests
+   gl = gitlab.Gitlab(url, token, api_version=4, retry_transient_errors=True)
+   gl.projects.list(get_all=True)                               # retries due to default value
+   gl.projects.list(get_all=True, retry_transient_errors=False) # does not retry
+
+Timeout
+-------
+
+python-gitlab will by default use the ``timeout`` option from its configuration
+for all requests. This is passed downwards to the ``requests`` module at the
+time of making the HTTP request. However if you would like to override the
+global timeout parameter for a particular call, you can provide the ``timeout``
+parameter to that API invocation:
+
+.. code-block:: python
+
+   import gitlab
+
+   gl = gitlab.Gitlab(url, token, api_version=4)
+   gl.projects.import_github(ACCESS_TOKEN, 123456, "root", timeout=120.0)
+
+Typing
+------
+
+Generally, ``python-gitlab`` is a fully typed package. However, currently you may still
+need to do some
+`type narrowing <https://mypy.readthedocs.io/en/stable/type_narrowing.html#type-narrowing>`_
+on your own, such as for nested API responses and ``Union`` return types. For example:
+
+.. code-block:: python
+
+   from typing import TYPE_CHECKING
+
+   import gitlab
+
+   gl = gitlab.Gitlab(url, token, api_version=4)
+   license = gl.get_license()
+
+   if TYPE_CHECKING:
+      assert isinstance(license["plan"], str)
+
+Per request HTTP headers override
+---------------------------------
+
+The ``extra_headers`` keyword argument can be used to add and override
+the HTTP headers for a specific request. For example, it can be used do add ``Range``
+header to download a part of artifacts archive:
+
+.. code-block:: python
+
+   import gitlab
+
+   gl = gitlab.Gitlab(url, token)
+   project = gl.projects.get(1)
+   job = project.jobs.get(123)
+
+   artifacts = job.artifacts(extra_headers={"Range": "bytes=0-9"})
diff --git a/docs/api-usage-graphql.rst b/docs/api-usage-graphql.rst
new file mode 100644
index 000000000..d20aeeef1
--- /dev/null
+++ b/docs/api-usage-graphql.rst
@@ -0,0 +1,74 @@
+############################
+Using the GraphQL API (beta)
+############################
+
+python-gitlab provides basic support for executing GraphQL queries and mutations,
+providing both a synchronous and asynchronous client.
+
+.. danger::
+
+   The GraphQL client is experimental and only provides basic support.
+   It does not currently support pagination, obey rate limits,
+   or attempt complex retries. You can use it to build simple queries and mutations.
+
+   It is currently unstable and its implementation may change. You can expect a more
+   mature client in one of the upcoming versions.
+
+The ``gitlab.GraphQL`` and ``gitlab.AsyncGraphQL`` classes
+==========================================================
+
+As with the REST client, you connect to a GitLab instance by creating a ``gitlab.GraphQL``
+(for synchronous code) or ``gitlab.AsyncGraphQL`` instance (for asynchronous code):
+
+.. code-block:: python
+
+   import gitlab
+
+   # anonymous read-only access for public resources (GitLab.com)
+   gq = gitlab.GraphQL()
+
+   # anonymous read-only access for public resources (self-hosted GitLab instance)
+   gq = gitlab.GraphQL('https://gitlab.example.com')
+
+   # personal access token or OAuth2 token authentication (GitLab.com)
+   gq = gitlab.GraphQL(token='glpat-JVNSESs8EwWRx5yDxM5q')
+
+   # personal access token or OAuth2 token authentication (self-hosted GitLab instance)
+   gq = gitlab.GraphQL('https://gitlab.example.com', token='glpat-JVNSESs8EwWRx5yDxM5q')
+
+   # or the async equivalents
+   async_gq = gitlab.AsyncGraphQL()
+   async_gq = gitlab.AsyncGraphQL('https://gitlab.example.com')
+   async_gq = gitlab.AsyncGraphQL(token='glpat-JVNSESs8EwWRx5yDxM5q')
+   async_gq = gitlab.AsyncGraphQL('https://gitlab.example.com', token='glpat-JVNSESs8EwWRx5yDxM5q')
+  
+Sending queries
+===============
+
+Get the result of a query:
+
+.. code-block:: python
+
+    query = """
+    {
+        currentUser {
+            name
+        }
+    }
+    """
+
+    result = gq.execute(query)
+
+Get the result of a query using the async client:
+
+.. code-block:: python
+
+    query = """
+    {
+        currentUser {
+            name
+        }
+    }
+    """
+
+    result = await async_gq.execute(query)
diff --git a/docs/api-usage.rst b/docs/api-usage.rst
index 4f8cb3717..38836f20f 100644
--- a/docs/api-usage.rst
+++ b/docs/api-usage.rst
@@ -1,36 +1,61 @@
-############################
-Getting started with the API
-############################
+##################
+Using the REST API
+##################
 
-The ``gitlab`` package provides 3 basic types:
-
-* ``gitlab.Gitlab`` is the primary class, handling the HTTP requests. It holds
-  the GitLab URL and authentication information.
-* ``gitlab.GitlabObject`` is the base class for all the GitLab objects. These
-  objects provide an abstraction for GitLab resources (projects, groups, and so
-  on).
-* ``gitlab.BaseManager`` is the base class for objects managers, providing the
-  API to manipulate the resources and their attributes.
+python-gitlab currently only supports v4 of the GitLab REST API.
 
 ``gitlab.Gitlab`` class
 =======================
 
-To connect to a GitLab server, create a ``gitlab.Gitlab`` object:
+To connect to GitLab.com or another GitLab instance, create a ``gitlab.Gitlab`` object:
+
+.. hint::
+
+   You can use different types of tokens for authenticated requests against the GitLab API.
+   You will most likely want to use a resource (project/group) access token or a personal
+   access token.
+
+   For the full list of available options and how to obtain these tokens, please see
+   https://docs.gitlab.com/api/rest/authentication/.
 
 .. code-block:: python
 
    import gitlab
 
-   # private token authentication
-   gl = gitlab.Gitlab('http://10.0.0.1', 'JVNSESs8EwWRx5yDxM5q')
+   # anonymous read-only access for public resources (GitLab.com)
+   gl = gitlab.Gitlab()
+
+   # anonymous read-only access for public resources (self-hosted GitLab instance)
+   gl = gitlab.Gitlab('https://gitlab.example.com')
+
+   # private token or personal token authentication (GitLab.com)
+   gl = gitlab.Gitlab(private_token='JVNSESs8EwWRx5yDxM5q')
+
+   # private token or personal token authentication (self-hosted GitLab instance)
+   gl = gitlab.Gitlab(url='https://gitlab.example.com', private_token='JVNSESs8EwWRx5yDxM5q')
 
-   # or username/password authentication
-   gl = gitlab.Gitlab('http://10.0.0.1', email='jdoe', password='s3cr3t')
+   # oauth token authentication
+   gl = gitlab.Gitlab('https://gitlab.example.com', oauth_token='my_long_token_here')
 
-   # make an API request to create the gl.user object. This is mandatory if you
-   # use the username/password authentication.
+   # job token authentication (to be used in CI)
+   # bear in mind the limitations of the API endpoints it supports:
+   # https://docs.gitlab.com/ci/jobs/ci_job_token
+   import os
+   gl = gitlab.Gitlab('https://gitlab.example.com', job_token=os.environ['CI_JOB_TOKEN'])
+
+   # Define your own custom user agent for requests
+   gl = gitlab.Gitlab('https://gitlab.example.com', user_agent='my-package/1.0.0')
+
+   # make an API request to create the gl.user object. This is not required but may be useful
+   # to validate your token authentication. Note that this will not work with job tokens.
    gl.auth()
 
+   # Enable "debug" mode. This can be useful when trying to determine what
+   # information is being sent back and forth to the GitLab server.
+   # Note: this will cause credentials and other potentially sensitive
+   # information to be printed to the terminal.
+   gl.enable_debug()
+
 You can also use configuration files to create ``gitlab.Gitlab`` objects:
 
 .. code-block:: python
@@ -40,47 +65,123 @@ You can also use configuration files to create ``gitlab.Gitlab`` objects:
 See the :ref:`cli_configuration` section for more information about
 configuration files.
 
+.. warning::
+
+   Note that a url that results in 301/302 redirects will raise an error,
+   so it is highly recommended to use the final destination in the ``url`` field.
+   For example, if the GitLab server you are using redirects requests from http
+   to https, make sure to use the ``https://`` protocol in the URL definition.
+
+   A URL that redirects using 301/302 (rather than 307/308) will most likely
+   `cause malformed POST and PUT requests <https://github.com/psf/requests/blob/c45a4dfe6bfc6017d4ea7e9f051d6cc30972b310/requests/sessions.py#L324-L332>`_.
+
+   python-gitlab will therefore raise a ``RedirectionError`` when it encounters
+   a redirect which it believes will cause such an error, to avoid confusion
+   between successful GET and failing POST/PUT requests on the same instance.
+
+Note on password authentication
+-------------------------------
+
+GitLab has long removed password-based basic authentication. You can currently still use the
+`resource owner password credentials <https://docs.gitlab.com/api/oauth2#resource-owner-password-credentials-flow>`_
+flow to obtain an OAuth token.
+
+However, we do not recommend this as it will not work with 2FA enabled, and GitLab is removing
+ROPC-based flows without client IDs in a future release. We recommend you obtain tokens for
+automated workflows as linked above or obtain a session cookie from your browser.
+
+For a python example of password authentication using the ROPC-based OAuth2
+flow, see `this Ansible snippet <https://github.com/ansible-collections/community.general/blob/1c06e237c8100ac30d3941d5a3869a4428ba2974/plugins/module_utils/gitlab.py#L86-L92>`_.
 
 Managers
 ========
 
 The ``gitlab.Gitlab`` class provides managers to access the GitLab resources.
 Each manager provides a set of methods to act on the resources. The available
-methods depend on the resource type. Resources are represented as
-``gitlab.GitlabObject``-derived objects.
+methods depend on the resource type.
 
 Examples:
 
 .. code-block:: python
 
    # list all the projects
-   projects = gl.projects.list()
+   projects = gl.projects.list(iterator=True)
    for project in projects:
        print(project)
 
    # get the group with id == 2
    group = gl.groups.get(2)
-   for group in groups:
-       print()
+   for project in group.projects.list(iterator=True):
+       print(project)
+
+.. warning::
+   Calling ``list()`` without any arguments will by default not return the complete list
+   of items. Use either the ``get_all=True`` or ``iterator=True`` parameters to get all the
+   items when using listing methods. See the :ref:`pagination` section for more
+   information.
+
+.. code-block:: python
 
    # create a new user
    user_data = {'email': 'jen@foo.com', 'username': 'jen', 'name': 'Jen'}
    user = gl.users.create(user_data)
    print(user)
 
-Some ``gitlab.GitlabObject`` classes also provide managers to access related
-GitLab resources:
+.. note:: 
+   python-gitlab attempts to sync the required, optional, and mutually exclusive attributes
+   for resource creation and update with the upstream API.
+   
+   You are encouraged to follow upstream API documentation for each resource to find these -
+   each resource documented here links to the corresponding upstream resource documentation
+   at the top of the page.
+
+The attributes of objects are defined upon object creation, and depend on the
+GitLab API itself. To list the available information associated with an object
+use the ``attributes`` attribute:
+
+.. code-block:: python
+
+   project = gl.projects.get(1)
+   print(project.attributes)
+
+Some objects also provide managers to access related GitLab resources:
 
 .. code-block:: python
 
    # list the issues for a project
    project = gl.projects.get(1)
-   issues = project.issues.list()
+   issues = project.issues.list(get_all=True)
+
+python-gitlab allows to send any data to the GitLab server when making queries.
+In case of invalid or missing arguments python-gitlab will raise an exception
+with the GitLab server error message:
+
+.. code-block:: python
+
+   >>> gl.projects.list(get_all=True, sort='invalid value')
+   ...
+   GitlabListError: 400: sort does not have a valid value
+
+.. _conflicting_parameters:
+
+Conflicting Parameters
+======================
+
+You can use the ``query_parameters`` argument to send arguments that would
+conflict with python or python-gitlab when using them as kwargs:
+
+.. code-block:: python
+
+   gl.user_activities.list(from='2019-01-01', iterator=True)  ## invalid
+
+   gl.user_activities.list(query_parameters={'from': '2019-01-01'}, iterator=True)  # OK
+
+.. _objects:
 
 Gitlab Objects
 ==============
 
-You can update or delete an object when it exists as a ``GitlabObject`` object:
+You can update or delete a remote object when it exists locally:
 
 .. code-block:: python
 
@@ -93,9 +194,8 @@ You can update or delete an object when it exists as a ``GitlabObject`` object:
    # delete the resource
    project.delete()
 
-
-Some ``GitlabObject``-derived classes provide additional methods, allowing more
-actions on the GitLab resources. For example:
+Some classes provide additional methods, allowing more actions on the GitLab
+resources. For example:
 
 .. code-block:: python
 
@@ -103,6 +203,127 @@ actions on the GitLab resources. For example:
    project = gl.projects.get(1)
    project.star()
 
+You can print a Gitlab Object. For example:
+
+.. code-block:: python
+
+   project = gl.projects.get(1)
+   print(project)
+
+   # Or in a prettier format.
+   project.pprint()
+
+   # Or explicitly via ``pformat()``. This is equivalent to the above.
+   print(project.pformat())
+
+You can also extend the object if the parameter isn't explicitly listed. For example,
+if you want to update a field that has been newly introduced to the Gitlab API, setting
+the value on the object is accepted:
+
+.. code-block:: python
+
+   issues = project.issues.list(get_all=True, state='opened')
+   for issue in issues:
+      issue.my_super_awesome_feature_flag = "random_value"
+      issue.save()
+
+As a dictionary
+---------------
+
+You can get a dictionary representation copy of the Gitlab Object. Modifications made to
+the dictionary will have no impact on the GitLab Object.
+
+* ``asdict()`` method. Returns a dictionary representation of the Gitlab object.
+* ``attributes`` property. Returns a dictionary representation of the Gitlab
+   object. Also returns any relevant parent object attributes.
+
+.. code-block:: python
+
+   project = gl.projects.get(1)
+   project_dict = project.asdict()
+
+   # Or a dictionary representation also containing some of the parent attributes
+   issue = project.issues.get(1)
+   attribute_dict = issue.attributes
+
+   # The following will return the same value
+   title = issue.title
+   title = issue.attributes["title"]
+
+.. hint::
+
+   This can be used to access attributes that clash with python-gitlab's own methods or managers.
+   Note that:
+
+   ``attributes`` returns the parent object attributes that are defined in
+   ``object._from_parent_attrs``. For example, a ``ProjectIssue`` object will have a
+   ``project_id`` key in the dictionary returned from ``attributes`` but ``asdict()`` will not.
+
+As JSON
+-------
+
+You can get a JSON string represenation of the Gitlab Object. For example:
+
+.. code-block:: python
+
+   project = gl.projects.get(1)
+   print(project.to_json())
+   # Use arguments supported by ``json.dump()``
+   print(project.to_json(sort_keys=True, indent=4))
+
+Base types
+==========
+
+The ``gitlab`` package provides some base types.
+
+* ``gitlab.Gitlab`` is the primary class, handling the HTTP requests. It holds
+  the GitLab URL and authentication information.
+* ``gitlab.base.RESTObject`` is the base class for all the GitLab v4 objects.
+  These objects provide an abstraction for GitLab resources (projects, groups,
+  and so on).
+* ``gitlab.base.RESTManager`` is the base class for v4 objects managers,
+  providing the API to manipulate the resources and their attributes.
+
+Lazy objects
+============
+
+To avoid useless API calls to the server you can create lazy objects. These
+objects are created locally using a known ID, and give access to other managers
+and methods.
+
+The following example will only make one API call to the GitLab server to star
+a project (the previous example used 2 API calls):
+
+.. code-block:: python
+
+   # star a git repository
+   project = gl.projects.get(1, lazy=True)  # no API call
+   project.star()  # API call
+
+``head()`` methods
+========================
+
+All endpoints that support ``get()`` and ``list()`` also support a ``head()`` method.
+In this case, the server responds only with headers and not the response JSON or body.
+This allows more efficient API calls, such as checking repository file size without
+fetching its content.
+
+.. note::
+
+   In some cases, GitLab may omit specific headers. See more in the :ref:`pagination` section.
+
+.. code-block:: python
+
+   # See total number of personal access tokens for current user
+   gl.personal_access_tokens.head()
+   print(headers["X-Total"])
+
+   # See returned content-type for project GET endpoint
+   headers = gl.projects.head("gitlab-org/gitlab")
+   print(headers["Content-Type"])
+
+.. _pagination:
+
 Pagination
 ==========
 
@@ -113,25 +334,72 @@ listing methods support the ``page`` and ``per_page`` parameters:
 
    ten_first_groups = gl.groups.list(page=1, per_page=10)
 
-.. note::
+.. warning::
 
    The first page is page 1, not page 0.
 
-
-By default GitLab does not return the complete list of items.  Use the ``all``
+By default GitLab does not return the complete list of items. Use the ``get_all``
 parameter to get all the items when using listing methods:
 
 .. code-block:: python
 
-   all_groups = gl.groups.list(all=True)
-   all_owned_projects = gl.projects.owned(all=True)
+   all_groups = gl.groups.list(get_all=True)
+
+   all_owned_projects = gl.projects.list(owned=True, get_all=True)
+
+You can define the ``per_page`` value globally to avoid passing it to every
+``list()`` method call:
+
+.. code-block:: python
+
+   gl = gitlab.Gitlab(url, token, per_page=50)
+
+Gitlab allows to also use keyset pagination. You can supply it to your project listing,
+but you can also do so globally. Be aware that GitLab then also requires you to only use supported
+order options. At the time of writing, only ``order_by="id"`` works.
+
+.. code-block:: python
+
+   gl = gitlab.Gitlab(url, token, pagination="keyset", order_by="id", per_page=100)
+   gl.projects.list(get_all=True)
+
+Reference:
+https://docs.gitlab.com/api/rest/#keyset-based-pagination
+
+``list()`` methods can also return a generator object, by passing the argument
+``iterator=True``, which will handle the next calls to the API when required. This
+is the recommended way to iterate through a large number of items:
+
+.. code-block:: python
+
+   items = gl.groups.list(iterator=True)
+   for item in items:
+       print(item.attributes)
+
+The generator exposes extra listing information as received from the server:
+
+* ``current_page``: current page number (first page is 1)
+* ``prev_page``: if ``None`` the current page is the first one
+* ``next_page``: if ``None`` the current page is the last one
+* ``per_page``: number of items per page
+* ``total_pages``: total number of pages available. This may be a ``None`` value.
+* ``total``: total number of items in the list. This may be a ``None`` value.
 
 .. note::
 
-   python-gitlab will iterate over the list by calling the correspnding API
-   multiple times. This might take some time if you have a lot of items to
-   retrieve. This might also consume a lot of memory as all the items will be
-   stored in RAM.
+   For performance reasons, if a query returns more than 10,000 records, GitLab
+   does not return the ``total_pages`` or ``total`` headers.  In this case,
+   ``total_pages`` and ``total`` will have a value of ``None``.
+
+   For more information see:
+   https://docs.gitlab.com/user/gitlab_com/index#pagination-response-headers
+
+.. note::
+   Prior to python-gitlab 3.6.0 the argument ``as_list`` was used instead of
+   ``iterator``.  ``as_list=False`` is the equivalent of ``iterator=True``.
+
+.. note::
+   If ``page`` and ``iterator=True`` are used together, the latter is ignored.
 
 Sudo
 ====
@@ -142,3 +410,66 @@ user. For example:
 .. code-block:: python
 
    p = gl.projects.create({'name': 'awesome_project'}, sudo='user1')
+
+.. warning::
+   When using ``sudo``, its usage is not remembered. If you use ``sudo`` to
+   retrieve an object and then later use ``save()`` to modify the object, it
+   will not use ``sudo``.  You should use ``save(sudo='user1')`` if you want to
+   perform subsequent actions as the  user.
+
+Updating with ``sudo``
+----------------------
+
+An example of how to ``get`` an object (using ``sudo``), modify the object, and
+then ``save`` the object (using ``sudo``):
+
+.. code-block:: python
+
+   group = gl.groups.get('example-group')
+   notification_setting = group.notificationsettings.get(sudo='user1')
+   notification_setting.level = gitlab.const.NOTIFICATION_LEVEL_GLOBAL
+   # Must use 'sudo' again when doing the save.
+   notification_setting.save(sudo='user1')
+
+
+Logging
+=======
+
+To enable debug logging from the underlying ``requests`` and ``http.client`` calls,
+you can use ``enable_debug()`` on your ``Gitlab`` instance. For example:
+
+.. code-block:: python
+
+   import os
+   import gitlab
+
+   gl = gitlab.Gitlab(private_token=os.getenv("GITLAB_TOKEN"))
+   gl.enable_debug()
+
+By default, python-gitlab will mask the token used for authentication in logging output.
+If you'd like to debug credentials sent to the API, you can disable masking explicitly:
+
+.. code-block:: python
+
+   gl.enable_debug(mask_credentials=False)
+
+.. _object_attributes:
+
+Attributes in updated objects
+=============================
+
+When methods manipulate an existing object, such as with ``refresh()`` and ``save()``,
+the object will only have attributes that were returned by the server. In some cases,
+such as when the initial request fetches attributes that are needed later for additional
+processing, this may not be desired:
+
+.. code-block:: python
+
+   project = gl.projects.get(1, statistics=True)
+   project.statistics
+
+   project.refresh()
+   project.statistics # AttributeError
+
+To avoid this, either copy the object/attributes before calling ``refresh()``/``save()``
+or subsequently perform another ``get()`` call as needed, to fetch the attributes you want.
diff --git a/docs/api/gitlab.rst b/docs/api/gitlab.rst
index 72796eed4..5b60e7aa5 100644
--- a/docs/api/gitlab.rst
+++ b/docs/api/gitlab.rst
@@ -1,5 +1,5 @@
-gitlab package
-==============
+API reference (``gitlab`` package)
+==================================
 
 Module contents
 ---------------
@@ -8,8 +8,60 @@ Module contents
     :members:
     :undoc-members:
     :show-inheritance:
-    :exclude-members: Hook, UserProject, Group, Issue, Team, User,
-                      all_projects, owned_projects, search_projects
+    :noindex:
+
+.. autoclass:: gitlab.Gitlab
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+.. autoclass:: gitlab.GitlabList
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+
+Subpackages
+-----------
+
+.. toctree::
+
+    gitlab.v4
+
+Submodules
+----------
+
+gitlab.base module
+------------------
+
+.. automodule:: gitlab.base
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+gitlab.cli module
+-----------------
+
+.. automodule:: gitlab.cli
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+gitlab.config module
+--------------------
+
+.. automodule:: gitlab.config
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+gitlab.const module
+-------------------
+
+.. automodule:: gitlab.const
+    :members:
+    :undoc-members:
+    :show-inheritance:
 
 gitlab.exceptions module
 ------------------------
@@ -19,18 +71,18 @@ gitlab.exceptions module
     :undoc-members:
     :show-inheritance:
 
-gitlab.objects module
----------------------
+gitlab.mixins module
+--------------------
+
+.. automodule:: gitlab.mixins
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+gitlab.utils module
+-------------------
 
-.. automodule:: gitlab.objects
+.. automodule:: gitlab.utils
     :members:
     :undoc-members:
     :show-inheritance:
-    :exclude-members: Branch, Commit, Content, Event, File, Hook, Issue, Key,
-                      Label, Member, MergeRequest, Milestone, Note, Snippet,
-                      Tag, canGet, canList, canUpdate, canCreate, canDelete,
-                      requiredUrlAttrs, requiredListAttrs, optionalListAttrs,
-                      optionalGetAttrs, requiredGetAttrs, requiredDeleteAttrs,
-                      requiredCreateAttrs, optionalCreateAttrs,
-                      requiredUpdateAttrs, optionalUpdateAttrs, getRequiresId,
-                      shortPrintAttr, idAttr
diff --git a/docs/api/gitlab.v4.rst b/docs/api/gitlab.v4.rst
new file mode 100644
index 000000000..70358c110
--- /dev/null
+++ b/docs/api/gitlab.v4.rst
@@ -0,0 +1,22 @@
+gitlab.v4 package
+=================
+
+Submodules
+----------
+
+gitlab.v4.objects module
+------------------------
+
+.. automodule:: gitlab.v4.objects
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+
+Module contents
+---------------
+
+.. automodule:: gitlab.v4
+    :members:
+    :undoc-members:
+    :show-inheritance:
diff --git a/docs/api/modules.rst b/docs/api/modules.rst
deleted file mode 100644
index 7b09ae1b6..000000000
--- a/docs/api/modules.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-gitlab
-======
-
-.. toctree::
-   :maxdepth: 4
-
-   gitlab
diff --git a/docs/changelog.md b/docs/changelog.md
new file mode 100644
index 000000000..66efc0fec
--- /dev/null
+++ b/docs/changelog.md
@@ -0,0 +1,2 @@
+```{include} ../CHANGELOG.md
+```
diff --git a/docs/cli-examples.rst b/docs/cli-examples.rst
new file mode 100644
index 000000000..2ed1c5804
--- /dev/null
+++ b/docs/cli-examples.rst
@@ -0,0 +1,365 @@
+############
+CLI examples
+############
+
+.. seealso::
+
+      For a complete list of objects and actions available, see :doc:`/cli-objects`.
+
+CI Lint
+-------
+
+**ci-lint has been Removed in Gitlab 16, use project-ci-lint instead**
+
+Lint a CI YAML configuration from a string:
+
+.. note::
+
+   To see output, you will need to use the ``-v``/``--verbose`` flag.
+
+   To exit with non-zero on YAML lint failures instead, use the ``validate``
+   subcommand shown below.
+
+.. code-block:: console
+
+   $ gitlab --verbose ci-lint create --content \
+     "---
+     test:
+       script:
+         - echo hello
+     "
+
+Lint a CI YAML configuration from a file (see :ref:`cli_from_files`):
+
+.. code-block:: console
+
+   $ gitlab --verbose ci-lint create --content @.gitlab-ci.yml
+
+Validate a CI YAML configuration from a file (lints and exits with non-zero on failure):
+
+.. code-block:: console
+
+   $ gitlab ci-lint validate --content @.gitlab-ci.yml
+
+Project CI Lint
+---------------
+
+Lint a project's CI YAML configuration:
+
+.. code-block:: console
+
+   $ gitlab --verbose project-ci-lint create --project-id group/my-project --content @.gitlab-ci.yml
+
+Validate a project's CI YAML configuration (lints and exits with non-zero on failure):
+
+.. code-block:: console
+
+   $ gitlab project-ci-lint validate --project-id group/my-project --content @.gitlab-ci.yml
+
+Lint a project's current CI YAML configuration:
+
+.. code-block:: console
+
+   $ gitlab --verbose project-ci-lint get --project-id group/my-project
+
+Lint a project's current CI YAML configuration on a specific branch:
+
+.. code-block:: console
+
+   $ gitlab --verbose project-ci-lint get --project-id group/my-project --ref my-branch
+
+Projects
+--------
+
+List the projects (paginated):
+
+.. code-block:: console
+
+   $ gitlab project list
+
+List all the projects:
+
+.. code-block:: console
+
+   $ gitlab project list --get-all
+
+List all projects of a group:
+
+.. code-block:: console
+
+   $ gitlab group-project list --get-all --group-id 1
+
+List all projects of a group and its subgroups:
+
+.. code-block:: console
+
+   $ gitlab group-project list --get-all --include-subgroups true --group-id 1
+
+Limit to 5 items per request, display the 1st page only
+
+.. code-block:: console
+
+   $ gitlab project list --page 1 --per-page 5
+
+Get a specific project (id 2):
+
+.. code-block:: console
+
+   $ gitlab project get --id 2
+
+Users
+-----
+
+Get a specific user by id:
+
+.. code-block:: console
+
+   $ gitlab user get --id 3
+
+Create a user impersonation token (admin-only):
+
+.. code-block:: console
+
+   gitlab user-impersonation-token create --user-id 2 --name test-token --scopes api,read_user
+
+Deploy tokens
+-------------
+
+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 "api,read_repository"
+
+List deploy tokens for a group:
+
+.. code-block:: console
+
+   $ gitlab -v group-deploy-token list --group-id 3
+
+Personal access tokens
+----------------------
+
+List the current user's personal access tokens (or all users' tokens, if admin):
+
+.. code-block:: console
+
+   $ gitlab -v personal-access-token list
+
+Revoke a personal access token by id:
+
+.. code-block:: console
+
+   $ gitlab personal-access-token delete --id 1
+
+Revoke the personal access token currently used:
+
+.. code-block:: console
+
+   $ gitlab personal-access-token delete --id self
+
+Create a personal access token for a user (admin only):
+
+.. code-block:: console
+
+   $ gitlab -v user-personal-access-token create --user-id 2 \
+        --name personal-access-token --expires-at "2023-01-01" --scopes "api,read_repository"
+
+Resource access tokens
+----------------------
+
+Create a project access token:
+
+.. code-block:: console
+
+   $ gitlab -v project-access-token create --project-id 2 \
+        --name project-token --expires-at "2023-01-01" --scopes "api,read_repository"
+
+List project access tokens:
+
+.. code-block:: console
+
+   $ gitlab -v project-access-token list --project-id 3
+
+Revoke a project access token:
+
+.. code-block:: console
+
+   $ gitlab project-access-token delete --project-id 3 --id 1
+
+Create a group access token:
+
+.. code-block:: console
+
+   $ gitlab -v group-access-token create --group-id 2 \
+        --name group-token --expires-at "2022-01-01" --scopes "api,read_repository"
+
+List group access tokens:
+
+.. code-block:: console
+
+   $ gitlab -v group-access-token list --group-id 3
+
+Revoke a group access token:
+
+.. code-block:: console
+
+   $ gitlab group-access-token delete --group-id 3 --id 1
+
+Packages
+--------
+
+List packages for a project:
+
+.. code-block:: console
+
+   $ gitlab -v project-package list --project-id 3
+
+List packages for a group:
+
+.. code-block:: console
+
+   $ gitlab -v group-package list --group-id 3
+
+Get a specific project package by id:
+
+.. code-block:: console
+
+   $ gitlab -v project-package get --id 1 --project-id 3
+
+Delete a specific project package by id:
+
+.. code-block:: console
+
+   $ gitlab -v project-package delete --id 1 --project-id 3
+
+Upload a generic package to a project:
+
+.. code-block:: console
+
+   $ gitlab generic-package upload --project-id 1 --package-name hello-world \
+        --package-version v1.0.0 --file-name hello.tar.gz --path /path/to/hello.tar.gz
+
+Download a project's generic package:
+
+.. code-block:: console
+
+   $ gitlab generic-package download --project-id 1 --package-name hello-world \
+        --package-version v1.0.0 --file-name hello.tar.gz > /path/to/hello.tar.gz
+
+Issues
+------
+
+Get a list of issues for this project:
+
+.. code-block:: console
+
+   $ gitlab project-issue list --project-id 2
+
+Snippets
+--------
+
+Delete a snippet (id 3):
+
+.. code-block:: console
+
+   $ gitlab project-snippet delete --id 3 --project-id 2
+
+Update a snippet:
+
+.. code-block:: console
+
+   $ gitlab project-snippet update --id 4 --project-id 2 \
+       --code "My New Code"
+
+Create a snippet:
+
+.. code-block:: console
+
+   $ gitlab project-snippet create --project-id 2
+   Impossible to create object (Missing attribute(s): title, file-name, code)
+   $ # oops, let's add the attributes:
+   $ gitlab project-snippet create --project-id 2 --title "the title" \
+       --file-name "the name" --code "the code"
+
+Commits
+-------
+
+Get a specific project commit by its SHA id:
+
+.. code-block:: console
+
+   $ gitlab project-commit get --project-id 2 --id a43290c
+
+Get the signature (e.g. GPG or x509) of a signed commit:
+
+.. code-block:: console
+
+   $ gitlab project-commit signature --project-id 2 --id a43290c
+
+Define the status of a commit (as would be done from a CI tool for example):
+
+.. code-block:: console
+
+   $ gitlab project-commit-status create --project-id 2 \
+       --commit-id a43290c --state success --name ci/jenkins \
+       --target-url http://server/build/123 \
+       --description "Jenkins build succeeded"
+
+Get the merge base for two or more branches, tags or commits:
+
+.. code-block:: console
+
+    gitlab project repository-merge-base --id 1 --refs bd1324e2f,main,v1.0.0
+
+Artifacts
+---------
+
+Download the artifacts zip archive of a job:
+
+.. code-block:: console
+
+   $ gitlab project-job artifacts --id 10 --project-id 1 > artifacts.zip
+
+Runners
+-------
+
+List owned runners:
+
+.. code-block:: console
+
+   $ gitlab runner list
+
+List owned runners with a filter:
+
+.. code-block:: console
+
+   $ gitlab runner list --scope active
+
+List all runners in the GitLab instance (specific and shared):
+
+.. code-block:: console
+
+   $ gitlab runner-all list
+
+Get a runner's details:
+
+.. code-block:: console
+
+   $ gitlab -v runner get --id 123
+
+Other
+-----
+
+Use sudo to act as another user (admin only):
+
+.. code-block:: console
+
+   $ gitlab project create --name user_project1 --sudo username
+
+List values are comma-separated:
+
+.. code-block:: console
+
+   $ gitlab issue list --labels foo,bar
\ No newline at end of file
diff --git a/docs/cli-objects.rst b/docs/cli-objects.rst
new file mode 100644
index 000000000..d6648f6a4
--- /dev/null
+++ b/docs/cli-objects.rst
@@ -0,0 +1,17 @@
+##################################
+CLI reference (``gitlab`` command)
+##################################
+
+.. warning::
+
+    The following is a complete, auto-generated list of subcommands available
+    via the :command:`gitlab` command-line tool. Some of the actions may
+    currently not work as expected or lack functionality available via the API.
+
+    Please see the existing `list of CLI related issues`_, or open a new one if
+    it is not already listed there.
+
+.. _list of CLI related issues: https://github.com/python-gitlab/python-gitlab/issues?q=is%3Aopen+is%3Aissue+label%3Acli
+
+.. autoprogram:: gitlab.cli:docs()
+   :prog: gitlab
diff --git a/docs/cli-usage.rst b/docs/cli-usage.rst
new file mode 100644
index 000000000..d56388e37
--- /dev/null
+++ b/docs/cli-usage.rst
@@ -0,0 +1,375 @@
+#############
+Using the CLI
+#############
+
+``python-gitlab`` provides a :command:`gitlab` command-line tool to interact
+with GitLab servers.
+
+This is especially convenient for running quick ad-hoc commands locally, easily
+interacting with the API inside GitLab CI, or with more advanced shell scripting
+when integrating with other tooling.
+
+.. _cli_configuration:
+
+Configuration
+=============
+
+``gitlab`` allows setting configuration options via command-line arguments,
+environment variables, and configuration files.
+
+For a complete list of global CLI options and their environment variable
+equivalents, see :doc:`/cli-objects`.
+
+With no configuration provided, ``gitlab`` will default to unauthenticated
+requests against `GitLab.com <https://gitlab.com>`__.
+
+With no configuration but running inside a GitLab CI job, it will default to
+authenticated requests using the current job token against the current instance
+(via ``CI_SERVER_URL`` and ``CI_JOB_TOKEN`` environment variables).
+
+.. warning::
+   Please note the job token has very limited permissions and can only be used
+   with certain endpoints. You may need to provide a personal access token instead.
+
+When you provide configuration, values are evaluated with the following precedence:
+
+1. Explicitly provided CLI arguments,
+2. Environment variables,
+3. Configuration files:
+
+   a. explicitly defined config files:
+
+      i. via the ``--config-file`` CLI argument,
+      ii. via the ``PYTHON_GITLAB_CFG`` environment variable,
+
+   b. user-specific config file,
+   c. system-level config file,
+
+4. Environment variables always present in CI (``CI_SERVER_URL``, ``CI_JOB_TOKEN``).
+
+Additionally, authentication will take the following precedence
+when multiple options or environment variables are present:
+
+1. Private token,
+2. OAuth token,
+3. CI job token.
+
+
+Configuration files
+-------------------
+
+``gitlab`` looks up 3 configuration files by default:
+
+The ``PYTHON_GITLAB_CFG`` environment variable
+    An environment variable that contains the path to a configuration file.
+
+``/etc/python-gitlab.cfg``
+    System-wide configuration file
+
+``~/.python-gitlab.cfg``
+    User configuration file
+
+You can use a different configuration file with the ``--config-file`` option.
+
+.. warning::
+    If the ``PYTHON_GITLAB_CFG`` environment variable is defined and the target
+    file exists, it will be the only configuration file parsed by ``gitlab``.  
+
+    If the environment variable is defined and the target file cannot be accessed,
+    ``gitlab`` will fail explicitly.
+
+Configuration file format
+-------------------------
+
+The configuration file uses the ``INI`` format. It contains at least a
+``[global]`` section, and a specific section for each GitLab server. For
+example:
+
+.. code-block:: ini
+
+   [global]
+   default = somewhere
+   ssl_verify = true
+   timeout = 5
+
+   [somewhere]
+   url = https://some.whe.re
+   private_token = vTbFeqJYCY3sibBP7BZM
+   api_version = 4
+
+   [elsewhere]
+   url = http://else.whe.re:8080
+   private_token = helper: path/to/helper.sh
+   timeout = 1
+
+The ``default`` option of the ``[global]`` section defines the GitLab server to
+use if no server is explicitly specified with the ``--gitlab`` CLI option.
+
+The ``[global]`` section also defines the values for the default connection
+parameters. You can override the values in each GitLab server section.
+
+.. list-table:: Global options
+   :header-rows: 1
+
+   * - Option
+     - Possible values
+     - Description
+   * - ``ssl_verify``
+     - ``True``, ``False``, or a ``str``
+     - Verify the SSL certificate. Set to ``False`` to disable verification,
+       though this will create warnings. Any other value is interpreted as path
+       to a CA_BUNDLE file or directory with certificates of trusted CAs.
+   * - ``timeout``
+     - Integer
+     - Number of seconds to wait for an answer before failing.
+   * - ``api_version``
+     - ``4``
+     - The API version to use to make queries. Only ``4`` is available since 1.5.0.
+   * - ``per_page``
+     - Integer between 1 and 100
+     - The number of items to return in listing queries. GitLab limits the
+       value at 100.
+   * - ``user_agent``
+     - ``str``
+     - A string defining a custom user agent to use when ``gitlab`` makes requests.
+
+You must define the ``url`` in each GitLab server section.
+
+.. warning::
+
+   Note that a url that results in 301/302 redirects will raise an error,
+   so it is highly recommended to use the final destination in the ``url`` field.
+   For example, if the GitLab server you are using redirects requests from http
+   to https, make sure to use the ``https://`` protocol in the URL definition.
+
+   A URL that redirects using 301/302 (rather than 307/308) will most likely
+   `cause malformed POST and PUT requests <https://github.com/psf/requests/blob/c45a4dfe6bfc6017d4ea7e9f051d6cc30972b310/requests/sessions.py#L324-L332>`_.
+
+   python-gitlab will therefore raise a ``RedirectionError`` when it encounters
+   a redirect which it believes will cause such an error, to avoid confusion
+   between successful GET and failing POST/PUT requests on the same instance.
+
+Only one of ``private_token``, ``oauth_token`` or ``job_token`` should be
+defined. If neither are defined an anonymous request will be sent to the Gitlab
+server, with very limited permissions.
+
+We recommend that you use `Credential helpers`_ to securely store your tokens.
+
+.. list-table:: GitLab server options
+   :header-rows: 1
+
+   * - Option
+     - Description
+   * - ``url``
+     - URL for the GitLab server. Do **NOT** use a URL which redirects.
+   * - ``private_token``
+     - Your user token. Login/password is not supported. Refer to `the
+       official documentation
+       <https://docs.gitlab.com/user/profile/personal_access_tokens>`__
+       to learn how to obtain a token.
+   * - ``oauth_token``
+     - An Oauth token for authentication. The Gitlab server must be configured
+       to support this authentication method.
+   * - ``job_token``
+     - Your job token. See `the official documentation
+       <https://docs.gitlab.com/api/jobs#get-job-artifacts>`__
+       to learn how to obtain a token.
+   * - ``api_version``
+     - GitLab API version to use. Only ``4`` is available since 1.5.0.
+
+Credential helpers
+------------------
+
+For all configuration options that contain secrets (for example,
+``personal_token``, ``oauth_token``, ``job_token``), you can specify
+a helper program to retrieve the secret indicated by a ``helper:``
+prefix. This allows you to fetch values from a local keyring store
+or cloud-hosted vaults such as Bitwarden. Environment variables are
+expanded if they exist and ``~`` expands to your home directory.
+
+It is expected that the helper program prints the secret to standard output.
+To use shell features such as piping to retrieve the value, you will need
+to use a wrapper script; see below.
+
+Example for a `keyring <https://github.com/jaraco/keyring>`_ helper:
+
+.. code-block:: ini
+
+   [global]
+   default = somewhere
+   ssl_verify = true
+   timeout = 5
+
+   [somewhere]
+   url = http://somewhe.re
+   private_token = helper: keyring get Service Username
+   timeout = 1
+
+Example for a `pass <https://www.passwordstore.org>`_ helper with a wrapper script:
+
+.. code-block:: ini
+
+   [global]
+   default = somewhere
+   ssl_verify = true
+   timeout = 5
+
+   [somewhere]
+   url = http://somewhe.re
+   private_token = helper: /path/to/helper.sh
+   timeout = 1
+
+In ``/path/to/helper.sh``:
+
+.. code-block:: bash
+
+    #!/bin/bash
+    pass show path/to/credentials | head -n 1
+
+CLI
+===
+
+Objects and actions
+-------------------
+
+The ``gitlab`` command expects two mandatory arguments. The first one is the
+type of object that you want to manipulate. The second is the action that you
+want to perform. For example:
+
+.. code-block:: console
+
+   $ gitlab project list
+
+Use the ``--help`` option to list the available object types and actions:
+
+.. code-block:: console
+
+   $ gitlab --help
+   $ gitlab project --help
+
+Some actions require additional parameters. Use the ``--help`` option to
+list mandatory and optional arguments for an action:
+
+.. code-block:: console
+
+   $ gitlab project create --help
+
+Optional arguments
+------------------
+
+Use the following optional arguments to change the behavior of ``gitlab``.
+These options must be defined before the mandatory arguments.
+
+``--verbose``, ``-v``
+    Outputs detail about retrieved objects. Available for legacy (default)
+    output only.
+
+``--config-file``, ``-c``
+    Path to a configuration file.
+
+``--gitlab``, ``-g``
+    ID of a GitLab server defined in the configuration file.
+
+``--output``, ``-o``
+    Output format. Defaults to a custom format. Can also be ``yaml`` or ``json``.
+
+.. important::
+
+        The `PyYAML package <https://pypi.org/project/PyYAML/>`_ is required to use the yaml output option.
+        You need to install it explicitly using ``pip install python-gitlab[yaml]``
+
+``--fields``, ``-f``
+    Comma-separated list of fields to display (``yaml`` and ``json`` output
+    formats only).  If not used, all the object fields are displayed.
+
+Example:
+
+.. code-block:: console
+
+   $ gitlab -o yaml -f id,permissions -g elsewhere -c /tmp/gl.cfg project list
+
+.. _cli_from_files:
+
+Reading values from files
+-------------------------
+
+You can make ``gitlab`` read values from files instead of providing them on the
+command line. This is handy for values containing new lines for instance:
+
+.. code-block:: console
+
+   $ cat > /tmp/description << EOF
+   This is the description of my project.
+
+   It is obviously the best project around
+   EOF
+   $ gitlab project create --name SuperProject --description @/tmp/description
+
+It you want to explicitly pass an argument starting with ``@``,  you can escape it using ``@@``:
+
+.. code-block:: console
+  
+   $ gitlab project-tag list --project-id somenamespace/myproject
+   ...
+   name: @at-started-tag
+   ...
+   $ gitlab project-tag delete --project-id somenamespace/myproject --name '@@at-started-tag'
+
+
+Enabling shell autocompletion
+=============================
+
+To get autocompletion, you'll need to install the package with the extra
+"autocompletion":
+
+.. code-block:: console
+
+    pip install python_gitlab[autocompletion]
+
+
+Add the appropriate command below to your shell's config file so that it is run on
+startup. You will likely have to restart or re-login for the autocompletion to
+start working.
+
+Bash
+----
+
+.. code-block:: console
+
+   eval "$(register-python-argcomplete gitlab)"
+
+tcsh
+----
+
+.. code-block:: console
+
+   eval ``register-python-argcomplete --shell tcsh gitlab``
+
+fish
+----
+
+.. code-block:: console
+
+   register-python-argcomplete --shell fish gitlab | .
+
+Zsh
+---
+
+.. warning::
+
+    Zsh autocompletion support is broken right now in the argcomplete python
+    package. Perhaps it will be fixed in a future release of argcomplete at
+    which point the following instructions will enable autocompletion in zsh.
+
+To activate completions for zsh you need to have bashcompinit enabled in zsh:
+
+.. code-block:: console
+
+   autoload -U bashcompinit
+   bashcompinit
+
+Afterwards you can enable completion for gitlab:
+
+.. code-block:: console
+
+   eval "$(register-python-argcomplete gitlab)"
diff --git a/docs/cli.rst b/docs/cli.rst
deleted file mode 100644
index 8b79d78fb..000000000
--- a/docs/cli.rst
+++ /dev/null
@@ -1,213 +0,0 @@
-####################
-``gitlab`` CLI usage
-####################
-
-``python-gitlab`` provides a :command:`gitlab` command-line tool to interact
-with GitLab servers. It uses a configuration file to define how to connect to
-the servers.
-
-.. _cli_configuration:
-
-Configuration
-=============
-
-Files
------
-
-``gitlab`` looks up 2 configuration files by default:
-
-``/etc/python-gitlab.cfg``
-    System-wide configuration file
-
-``~/.python-gitlab.cfg``
-    User configuration file
-
-You can use a different configuration file with the ``--config-file`` option.
-
-Content
--------
-
-The configuration file uses the ``INI`` format. It contains at least a
-``[global]`` section, and a new section for each GitLab server. For example:
-
-.. code-block:: ini
-
-   [global]
-   default = somewhere
-   ssl_verify = true
-   timeout = 5
-
-   [somewhere]
-   url = https://some.whe.re
-   private_token = vTbFeqJYCY3sibBP7BZM
-
-   [elsewhere]
-   url = http://else.whe.re:8080
-   private_token = CkqsjqcQSFH5FQKDccu4
-   timeout = 1
-
-The ``default`` option of the ``[global]`` section defines the GitLab server to
-use if no server is explitly specified with the ``--gitlab`` CLI option.
-
-The ``[global]`` section also defines the values for the default connexion
-parameters. You can override the values in each GitLab server section.
-
-.. list-table:: Global options
-   :header-rows: 1
-
-   * - Option
-     - Possible values
-     - Description
-   * - ``ssl_verify``
-     - ``True`` or ``False``
-     - Verify the SSL certificate. Set to ``False`` if your SSL certificate is
-       auto-signed.
-   * - ``timeout``
-     - Integer
-     - Number of seconds to wait for an answer before failing.
-
-You must define the ``url`` and ``private_token`` in each GitLab server
-section.
-
-.. list-table:: GitLab server options
-   :header-rows: 1
-
-   * - Option
-     - Description
-   * - ``url``
-     - URL for the GitLab server
-   * - ``private_token``
-     - Your user token. Login/password is not supported.
-   * - ``http_username``
-     - Username for optional HTTP authentication
-   * - ``http_password``
-     - Password for optional HTTP authentication
-
-CLI
-===
-
-Objects and actions
--------------------
-
-The ``gitlab`` command expects two mandatory arguments. This first one is the
-type of object that you want to manipulate. The second is the action that you
-want to perform. For example:
-
-.. code-block:: console
-
-   $ gitlab project list
-
-Use the ``--help`` option to list the available object types and actions:
-
-.. code-block:: console
-
-   $ gitlab --help
-   $ gitlab project --help
-
-Some actions require additional parameters. Use the ``--help`` option to
-list mandatory and optional arguments for an action:
-
-.. code-block:: console
-
-   $ gitlab project create --help
-
-Optional arguments
-------------------
-
-Use the following optional arguments to change the behavior of ``gitlab``.
-These options must be defined before the mandatory arguments.
-
-``--verbose``, ``-v``
-    Outputs detail about retrieved objects.
-
-``--config-file``, ``-c``
-    Path to a configuration file.
-
-``--gitlab``, ``-g``
-    ID of a GitLab server defined in the configuration file.
-
-Example:
-
-.. code-block:: console
-
-   $ gitlab -v -g elsewhere -c /tmp/gl.cfg project list
-
-
-Examples
-========
-
-List the projects (paginated):
-
-.. code-block:: console
-
-   $ gitlab project list
-
-List all the projects:
-
-.. code-block:: console
-
-   $ gitlab project list --all
-
-Limit to 5 items per request, display the 1st page only
-
-.. code-block:: console
-
-   $ gitlab project list --page 1 --per-page 5
-
-Get a specific project (id 2):
-
-.. code-block:: console
-
-   $ gitlab project get --id 2
-
-Get a specific user by id or by username:
-
-.. code-block:: console
-
-   $ gitlab user get --id 3
-   $ gitlab user get-by-username --query jdoe
-
-Get a list of snippets for this project:
-
-.. code-block:: console
-
-   $ gitlab project-issue list --project-id 2
-
-Delete a snippet (id 3):
-
-.. code-block:: console
-
-   $ gitlab project-snippet delete --id 3 --project-id 2
-
-Update a snippet:
-
-.. code-block:: console
-
-   $ gitlab project-snippet update --id 4 --project-id 2 \
-       --code "My New Code"
-
-Create a snippet:
-
-.. code-block:: console
-
-   $ gitlab project-snippet create --project-id 2
-   Impossible to create object (Missing attribute(s): title, file-name, code)
-
-   $ # oops, let's add the attributes:
-   $ gitlab project-snippet create --project-id 2 --title "the title" \
-       --file-name "the name" --code "the code"
-
-Define the status of a commit (as would be done from a CI tool for example):
-
-.. code-block:: console
-
-   $ gitlab project-commit-status create --project-id 2 \
-       --commit-id a43290c --state success --name ci/jenkins \
-       --target-url http://server/build/123 \
-       --description "Jenkins build succeeded"
-
-Use sudo to act as another user (admin only):
-
-.. code-block:: console
-
-   $ gitlab project create --name user_project1 --sudo username
diff --git a/docs/conf.py b/docs/conf.py
index 84e65175e..32e11abb9 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,5 +1,4 @@
 #!/usr/bin/env python3
-# -*- coding: utf-8 -*-
 #
 # python-gitlab documentation build configuration file, created by
 # sphinx-quickstart on Mon Dec  8 15:17:39 2014.
@@ -17,47 +16,73 @@
 
 import os
 import sys
+from datetime import datetime
 
-import sphinx
+from sphinx.domains.python import PythonDomain
 
-sys.path.append('../')
+sys.path.append("../")
 sys.path.append(os.path.dirname(__file__))
-import gitlab
+import gitlab  # noqa: E402. Needed purely for readthedocs' build
 
-on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
+
+# Sphinx will warn when attributes are exported in multiple places. See workaround:
+# https://github.com/sphinx-doc/sphinx/issues/3866#issuecomment-768167824
+# This patch can be removed when this issue is resolved:
+# https://github.com/sphinx-doc/sphinx/issues/4961
+class PatchedPythonDomain(PythonDomain):
+    def resolve_xref(self, env, fromdocname, builder, typ, target, node, contnode):
+        if "refspecific" in node:
+            del node["refspecific"]
+        return super(PatchedPythonDomain, self).resolve_xref(
+            env, fromdocname, builder, typ, target, node, contnode
+        )
+
+
+def setup(sphinx):
+    sphinx.add_domain(PatchedPythonDomain, override=True)
+
+
+on_rtd = os.environ.get("READTHEDOCS", None) == "True"
+year = datetime.now().year
 
 # If extensions (or modules to document with autodoc) are in another directory,
 # add these directories to sys.path here. If the directory is relative to the
 # documentation root, use os.path.abspath to make it absolute, like shown here.
-sys.path.insert(0, os.path.abspath('..'))
+sys.path.insert(0, os.path.abspath(".."))
 
 # -- General configuration ------------------------------------------------
 
 # If your documentation needs a minimal Sphinx version, state it here.
-#needs_sphinx = '1.0'
+# needs_sphinx = '1.0'
 
 # Add any Sphinx extension module names here, as strings. They can be
 # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
 # ones.
 extensions = [
-    'sphinx.ext.autodoc', 'sphinx.ext.autosummary', 'ext.docstrings'
+    "myst_parser",
+    "sphinx.ext.autodoc",
+    "sphinx.ext.autosummary",
+    "ext.docstrings",
+    "sphinxcontrib.autoprogram",
 ]
 
+autodoc_typehints = "both"
+
 # Add any paths that contain templates here, relative to this directory.
-templates_path = ['_templates']
+templates_path = ["_templates"]
 
 # The suffix of source filenames.
-source_suffix = '.rst'
+source_suffix = {".rst": "restructuredtext", ".md": "markdown"}
 
 # The encoding of source files.
-#source_encoding = 'utf-8-sig'
+# source_encoding = 'utf-8-sig'
 
 # The master toctree document.
-master_doc = 'index'
+root_doc = "index"
 
 # General information about the project.
-project = 'python-gitlab'
-copyright = '2013-2016, Gauvain Pocentek, Mika Mäenpää'
+project = "python-gitlab"
+copyright = gitlab.__copyright__
 
 # The version info for the project you're documenting, acts as replacement for
 # |version| and |release|, also used in various other places throughout the
@@ -70,175 +95,176 @@
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
-#language = None
+# language = None
 
 # There are two options for replacing |today|: either, you set today to some
 # non-false value, then it is used:
-#today = ''
+# today = ''
 # Else, today_fmt is used as the format for a strftime call.
-#today_fmt = '%B %d, %Y'
+# today_fmt = '%B %d, %Y'
 
 # List of patterns, relative to source directory, that match files and
 # directories to ignore when looking for source files.
-exclude_patterns = ['_build']
+exclude_patterns = ["_build"]
 
 # The reST default role (used for this markup: `text`) to use for all
 # documents.
-#default_role = None
+# default_role = None
 
 # If true, '()' will be appended to :func: etc. cross-reference text.
-#add_function_parentheses = True
+# add_function_parentheses = True
 
 # If true, the current module name will be prepended to all description
 # unit titles (such as .. function::).
-#add_module_names = True
+# add_module_names = True
 
 # If true, sectionauthor and moduleauthor directives will be shown in the
 # output. They are ignored by default.
-#show_authors = False
-
-# The name of the Pygments (syntax highlighting) style to use.
-pygments_style = 'sphinx'
+# show_authors = False
 
 # A list of ignored prefixes for module index sorting.
-#modindex_common_prefix = []
+# modindex_common_prefix = []
 
 # If true, keep warnings as "system message" paragraphs in the built documents.
-#keep_warnings = False
+# keep_warnings = False
 
 
 # -- Options for HTML output ----------------------------------------------
 
 # The theme to use for HTML and HTML Help pages.  See the documentation for
 # a list of builtin themes.
-html_theme = 'default'
-if not on_rtd: # only import and set the theme if we're building docs locally
-    try:
-        import sphinx_rtd_theme
-        html_theme = 'sphinx_rtd_theme'
-        html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
-    except ImportError: # Theme not found, use default
-        pass
+html_theme = "furo"
 
 # Theme options are theme-specific and customize the look and feel of a theme
 # further.  For a list of options available for each theme, see the
 # documentation.
-#html_theme_options = {}
+# html_theme_options = {}
 
 # Add any paths that contain custom themes here, relative to this directory.
-#html_theme_path = []
+# html_theme_path = []
 
 # The name for this set of Sphinx documents.  If None, it defaults to
 # "<project> v<release> documentation".
-#html_title = None
+html_title = f"{project} <small>v{release}</small>"
 
 # A shorter title for the navigation bar.  Default is the same as html_title.
-#html_short_title = None
+# html_short_title = None
 
 # The name of an image file (relative to this directory) to place at the top
 # of the sidebar.
-#html_logo = None
+# html_logo = None
 
 # The name of an image file (within the static path) to use as favicon of the
 # docs.  This file should be a Windows icon file (.ico) being 16x16 or 32x32
 # pixels large.
-#html_favicon = None
+# html_favicon = None
 
 # Add any paths that contain custom static files (such as style sheets) here,
 # relative to this directory. They are copied after the builtin static files,
 # so a file named "default.css" will overwrite the builtin "default.css".
-#html_static_path = ['_static']
+html_static_path = ["_static"]
+
+html_js_files = [
+    "js/gitter.js",
+    (
+        "https://sidecar.gitter.im/dist/sidecar.v1.js",
+        {"async": "async", "defer": "defer"},
+    ),
+]
 
 # Add any extra paths that contain custom files (such as robots.txt or
 # .htaccess) here, relative to this directory. These files are copied
 # directly to the root of the documentation.
-#html_extra_path = []
+# html_extra_path = []
 
 # If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
 # using the given strftime format.
-#html_last_updated_fmt = '%b %d, %Y'
+# html_last_updated_fmt = '%b %d, %Y'
 
 # If true, SmartyPants will be used to convert quotes and dashes to
 # typographically correct entities.
-#html_use_smartypants = True
+# html_use_smartypants = True
 
 # Custom sidebar templates, maps document names to template names.
-#html_sidebars = {}
+# html_sidebars = {}
 
 # Additional templates that should be rendered to pages, maps page names to
 # template names.
-#html_additional_pages = {}
+# html_additional_pages = {}
 
 # If false, no module index is generated.
-#html_domain_indices = True
+# html_domain_indices = True
 
 # If false, no index is generated.
-#html_use_index = True
+# html_use_index = True
 
 # If true, the index is split into individual pages for each letter.
-#html_split_index = False
+# html_split_index = False
 
 # If true, links to the reST sources are added to the pages.
-#html_show_sourcelink = True
+# html_show_sourcelink = True
 
 # If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
-#html_show_sphinx = True
+# html_show_sphinx = True
 
 # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
-#html_show_copyright = True
+# html_show_copyright = True
 
 # If true, an OpenSearch description file will be output, and all pages will
 # contain a <link> tag referring to it.  The value of this option must be the
 # base URL from which the finished HTML is served.
-#html_use_opensearch = ''
+# html_use_opensearch = ''
 
 # This is the file name suffix for HTML files (e.g. ".xhtml").
-#html_file_suffix = None
+# html_file_suffix = None
 
 # Output file base name for HTML help builder.
-htmlhelp_basename = 'python-gitlabdoc'
+htmlhelp_basename = "python-gitlabdoc"
 
 
 # -- Options for LaTeX output ---------------------------------------------
 
 latex_elements = {
-# The paper size ('letterpaper' or 'a4paper').
-#'papersize': 'letterpaper',
-
-# The font size ('10pt', '11pt' or '12pt').
-#'pointsize': '10pt',
-
-# Additional stuff for the LaTeX preamble.
-#'preamble': '',
+    # The paper size ('letterpaper' or 'a4paper').
+    # 'papersize': 'letterpaper',
+    #  The font size ('10pt', '11pt' or '12pt').
+    # 'pointsize': '10pt',
+    #  Additional stuff for the LaTeX preamble.
+    # 'preamble': '',
 }
 
 # Grouping the document tree into LaTeX files. List of tuples
 # (source start file, target name, title,
 #  author, documentclass [howto, manual, or own class]).
 latex_documents = [
-  ('index', 'python-gitlab.tex', 'python-gitlab Documentation',
-   'Gauvain Pocentek, Mika Mäenpää', 'manual'),
+    (
+        "index",
+        "python-gitlab.tex",
+        "python-gitlab Documentation",
+        "Gauvain Pocentek, Mika Mäenpää",
+        "manual",
+    )
 ]
 
 # The name of an image file (relative to this directory) to place at the top of
 # the title page.
-#latex_logo = None
+# latex_logo = None
 
 # For "manual" documents, if this is true, then toplevel headings are parts,
 # not chapters.
-#latex_use_parts = False
+# latex_use_parts = False
 
 # If true, show page references after internal links.
-#latex_show_pagerefs = False
+# latex_show_pagerefs = False
 
 # If true, show URL addresses after external links.
-#latex_show_urls = False
+# latex_show_urls = False
 
 # Documents to append as an appendix to all manuals.
-#latex_appendices = []
+# latex_appendices = []
 
 # If false, no module index is generated.
-#latex_domain_indices = True
+# latex_domain_indices = True
 
 
 # -- Options for manual page output ---------------------------------------
@@ -246,13 +272,19 @@
 # One entry per manual page. List of tuples
 # (source start file, name, description, authors, manual section).
 man_pages = [
-    ('index', 'python-gitlab', 'python-gitlab Documentation',
-     ['Gauvain Pocentek, Mika Mäenpää'], 1)
+    (
+        "index",
+        "python-gitlab",
+        "python-gitlab Documentation",
+        ["Gauvain Pocentek, Mika Mäenpää"],
+        1,
+    )
 ]
 
 # If true, show URL addresses after external links.
-#man_show_urls = False
+# man_show_urls = False
 
+nitpick_ignore_regex = [(r"py:.*", r".*")]
 
 # -- Options for Texinfo output -------------------------------------------
 
@@ -260,20 +292,25 @@
 # (source start file, target name, title, author,
 #  dir menu entry, description, category)
 texinfo_documents = [
-  ('index', 'python-gitlab', 'python-gitlab Documentation',
-   'Gauvain Pocentek, Mika Mäenpää', 'python-gitlab', 'One line description of project.',
-   'Miscellaneous'),
+    (
+        "index",
+        "python-gitlab",
+        "python-gitlab Documentation",
+        "Gauvain Pocentek, Mika Mäenpää",
+        "python-gitlab",
+        "One line description of project.",
+        "Miscellaneous",
+    )
 ]
 
 # Documents to append as an appendix to all manuals.
-#texinfo_appendices = []
+# texinfo_appendices = []
 
 # If false, no module index is generated.
-#texinfo_domain_indices = True
+# texinfo_domain_indices = True
 
 # How to display URL addresses: 'footnote', 'no', or 'inline'.
-#texinfo_show_urls = 'footnote'
+# texinfo_show_urls = 'footnote'
 
 # If true, do not generate a @detailmenu in the "Top" node's menu.
-#texinfo_no_detailmenu = False
-
+# texinfo_no_detailmenu = False
diff --git a/docs/ext/docstrings.py b/docs/ext/docstrings.py
index 5e5f82fa2..f71b68cda 100644
--- a/docs/ext/docstrings.py
+++ b/docs/ext/docstrings.py
@@ -1,61 +1,58 @@
-import itertools
+import inspect
 import os
+from typing import Sequence
 
 import jinja2
-import six
 import sphinx
 import sphinx.ext.napoleon as napoleon
+from sphinx.config import _ConfigRebuild
 from sphinx.ext.napoleon.docstring import GoogleDocstring
 
 
 def classref(value, short=True):
-    tilde = '~' if short else ''
-    return ':class:`%sgitlab.objects.%s`' % (tilde, value.__name__)
+    return value
+
+    if not inspect.isclass(value):
+        return f":class:{value}"
+    tilde = "~" if short else ""
+    return f":class:`{tilde}gitlab.objects.{value.__name__}`"
 
 
 def setup(app):
-    app.connect('autodoc-process-docstring', _process_docstring)
-    app.connect('autodoc-skip-member', napoleon._skip_member)
+    app.connect("autodoc-process-docstring", _process_docstring)
+    app.connect("autodoc-skip-member", napoleon._skip_member)
 
-    conf = napoleon.Config._config_values
+    conf: Sequence[tuple[str, bool | None, _ConfigRebuild, set[type]]] = (
+        napoleon.Config._config_values
+    )
 
-    for name, (default, rebuild) in six.iteritems(conf):
+    for name, default, rebuild, _ in conf:
         app.add_config_value(name, default, rebuild)
-    return {'version': sphinx.__display_version__, 'parallel_read_safe': True}
+    return {"version": sphinx.__display_version__, "parallel_read_safe": True}
 
 
 def _process_docstring(app, what, name, obj, options, lines):
     result_lines = lines
-    docstring = GitlabDocstring(result_lines, app.config, app, what, name, obj,
-                                options)
+    docstring = GitlabDocstring(result_lines, app.config, app, what, name, obj, options)
     result_lines = docstring.lines()
     lines[:] = result_lines[:]
 
 
 class GitlabDocstring(GoogleDocstring):
     def _build_doc(self, tmpl, **kwargs):
-        env = jinja2.Environment(loader=jinja2.FileSystemLoader(
-            os.path.dirname(__file__)), trim_blocks=False)
-        env.filters['classref'] = classref
+        env = jinja2.Environment(
+            loader=jinja2.FileSystemLoader(os.path.dirname(__file__)), trim_blocks=False
+        )
+        env.filters["classref"] = classref
         template = env.get_template(tmpl)
         output = template.render(**kwargs)
 
-        return output.split('\n')
-
-    def __init__(self, *args, **kwargs):
-        super(GitlabDocstring, self).__init__(*args, **kwargs)
-
-        if getattr(self._obj, '__name__', None) == 'Gitlab':
-            mgrs = []
-            gl = self._obj('http://dummy', private_token='dummy')
-            for item in vars(gl).items():
-                if hasattr(item[1], 'obj_cls'):
-                    mgrs.append(item)
-            self._parsed_lines.extend(self._build_doc('gl_tmpl.j2',
-                                                      mgrs=sorted(mgrs)))
-        elif hasattr(self._obj, 'obj_cls') and self._obj.obj_cls is not None:
-            self._parsed_lines.extend(self._build_doc('manager_tmpl.j2',
-                                                      cls=self._obj.obj_cls))
-        elif hasattr(self._obj, 'canUpdate') and self._obj.canUpdate:
-            self._parsed_lines.extend(self._build_doc('object_tmpl.j2',
-                                                      obj=self._obj))
+        return output.split("\n")
+
+    def __init__(
+        self, docstring, config=None, app=None, what="", name="", obj=None, options=None
+    ):
+        super().__init__(docstring, config, app, what, name, obj, options)
+
+        if name.startswith("gitlab.v4.objects") and name.endswith("Manager"):
+            self._parsed_lines.extend(self._build_doc("manager_tmpl.j2", cls=self._obj))
diff --git a/docs/ext/gl_tmpl.j2 b/docs/ext/gl_tmpl.j2
deleted file mode 100644
index dbccbcc61..000000000
--- a/docs/ext/gl_tmpl.j2
+++ /dev/null
@@ -1,5 +0,0 @@
-{% for attr, mgr in mgrs %}
-.. attribute:: {{ attr }}
-
-   {{ mgr.__class__ | classref() }} manager for {{ mgr.obj_cls | classref() }} objects.
-{% endfor %}
diff --git a/docs/ext/manager_tmpl.j2 b/docs/ext/manager_tmpl.j2
index fee8a568b..aef516496 100644
--- a/docs/ext/manager_tmpl.j2
+++ b/docs/ext/manager_tmpl.j2
@@ -1,84 +1,50 @@
-Manager for {{ cls | classref() }} objects.
-
-{% if cls.canUpdate %}
-{{ cls | classref() }} objects can be updated.
-{% else %}
-{{ cls | classref() }} objects **cannot** be updated.
+{% if cls._list_filters %}
+**Object listing filters**
+{% for item in cls._list_filters %}
+- ``{{ item }}``
+{% endfor %}
 {% endif %}
 
-{% if cls.canList %}
-.. method:: list(**kwargs)
-
-   Returns a list of objects of type {{ cls | classref() }}.
-
-   Available keys for ``kwargs`` are:
-
-   {% for k in cls.requiredListAttrs %}
-   * ``{{ k }}`` (required)
-   {% endfor %}
-   {% for k in cls.optionalListAttrs %}
-   * ``{{ k }}`` (optional)
-   {% endfor %}
-   * ``per_page`` (int): number of item per page. May be limited  by the server.
-   * ``page`` (int): page to retrieve
-   * ``all`` (bool): iterate over all the pages and return all the entries
-   * ``sudo`` (string or int): run the request as another user (requires admin
-     permissions)
+{% if cls._create_attrs %}
+**Object Creation**
+{% if cls._create_attrs.required %}
+Required attributes for object create:
+{% for item in cls._create_attrs.required %}
+- ``{{ item }}``
+{% endfor %}
 {% endif %}
-
-{% if cls.canGet %}
-{% if cls.getRequiresId %}
-.. method:: get(id, **kwargs)
-
-   Get a single object of type {{ cls | classref() }} using its ``id``.
-{% else %}
-.. method:: get(**kwargs)
-
-   Get a single object of type {{ cls | classref() }}.
+{% if cls._create_attrs.optional %}
+Optional attributes for object create:
+{% for item in cls._create_attrs.optional %}
+- ``{{ item }}``
+{% endfor %}
 {% endif %}
-
-   Available keys for ``kwargs`` are:
-
-   {% for k in cls.requiredGetAttrs %}
-   * ``{{ k }}`` (required)
-   {% endfor %}
-   {% for k in cls.optionalGetAttrs %}
-   * ``{{ k }}`` (optional)
-   {% endfor %}
-   * ``sudo`` (string or int): run the request as another user (requires admin
-     permissions)
+{% if cls._create_attrs.exclusive %}
+Mutually exclusive attributes for object create:
+{% for item in cls._create_attrs.exclusive %}
+- ``{{ item }}``
+{% endfor %}
 {% endif %}
-
-{% if cls.canCreate %}
-.. method:: create(data, **kwargs)
-
-   Create an object of type {{ cls | classref() }}.
-
-   ``data`` is a dict defining the object attributes. Available attributes are:
-
-   {% for a in cls.requiredUrlAttrs %}
-   * ``{{ a }}`` (required if not discovered on the parent objects)
-   {% endfor %}
-   {% for a in cls.requiredCreateAttrs %}
-   * ``{{ a }}`` (required)
-   {% endfor %}
-   {% for a in cls.optionalCreateAttrs %}
-   * ``{{ a }}`` (optional)
-   {% endfor %}
-
-   Available keys for ``kwargs`` are:
-
-   * ``sudo`` (string or int): run the request as another user (requires admin
-     permissions)
 {% endif %}
 
-{% if cls.canDelete %}
-.. method:: delete(id, **kwargs)
-
-   Delete the object with ID ``id``.
-
-   Available keys for ``kwargs`` are:
-
-   * ``sudo`` (string or int): run the request as another user (requires admin
-     permissions)
+{% if cls._update_attrs %}
+**Object update**
+{% if cls._update_attrs.required %}
+Required attributes for object update:
+{% for item in cls._update_attrs.required %}
+- ``{{ item }}``
+{% endfor %}
+{% endif %}
+{% if cls._update_attrs.optional %}
+Optional attributes for object update:
+{% for item in cls._update_attrs.optional %}
+- ``{{ item }}``
+{% endfor %}
+{% endif %}
+{% if cls._update_attrs.exclusive %}
+Mutually exclusive attributes for object update:
+{% for item in cls._update_attrs.exclusive %}
+- ``{{ item }}``
+{% endfor %}
+{% endif %}
 {% endif %}
diff --git a/docs/ext/object_tmpl.j2 b/docs/ext/object_tmpl.j2
deleted file mode 100644
index 4bb9070b5..000000000
--- a/docs/ext/object_tmpl.j2
+++ /dev/null
@@ -1,32 +0,0 @@
-{% for attr_name, cls, dummy in obj.managers %}
-.. attribute:: {{ attr_name }}
-
-   {{ cls | classref() }} - Manager for {{ cls.obj_cls | classref() }} objects.
-
-{% endfor %}
-
-.. method:: save(**kwargs)
-
-   Send the modified object to the GitLab server. The following attributes are
-   sent:
-
-{% if obj.requiredUpdateAttrs or obj.optionalUpdateAttrs %}
-   {% for a in obj.requiredUpdateAttrs %}
-   * ``{{ a }}`` (required)
-   {% endfor %}
-   {% for a in obj.optionalUpdateAttrs %}
-   * ``{{ a }}`` (optional)
-   {% endfor %}
-{% else %}
-   {% for a in obj.requiredCreateAttrs %}
-   * ``{{ a }}`` (required)
-   {% endfor %}
-   {% for a in obj.optionalCreateAttrs %}
-   * ``{{ a }}`` (optional)
-   {% endfor %}
-{% endif %}
-
-   Available keys for ``kwargs`` are:
-
-   * ``sudo`` (string or int): run the request as another user (requires admin
-     permissions)
diff --git a/docs/faq.rst b/docs/faq.rst
new file mode 100644
index 000000000..d28cf7861
--- /dev/null
+++ b/docs/faq.rst
@@ -0,0 +1,100 @@
+###
+FAQ
+###
+
+General
+-------
+
+I cannot edit the merge request / issue I've just retrieved.
+""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""
+
+It is likely that you used a ``MergeRequest``, ``GroupMergeRequest``,
+``Issue`` or ``GroupIssue`` object. These objects cannot be edited. But you
+can create a new ``ProjectMergeRequest`` or ``ProjectIssue`` object to
+apply changes. For example::
+
+    issue = gl.issues.list(get_all=False)[0]
+    project = gl.projects.get(issue.project_id, lazy=True)
+    editable_issue = project.issues.get(issue.iid, lazy=True)
+    # you can now edit the object
+
+See the :ref:`merge requests example <merge_requests_examples>` and the
+:ref:`issues examples <issues_examples>`.
+
+How can I clone the repository of a project?
+""""""""""""""""""""""""""""""""""""""""""""
+
+python-gitlab does not provide an API to clone a project. You have to use a
+git library or call the ``git`` command.
+
+The git URI is exposed in the ``ssh_url_to_repo`` attribute of ``Project``
+objects.
+
+Example::
+
+    import subprocess
+
+    project = gl.projects.create(data)  # or gl.projects.get(project_id)
+    print(project.attributes)  # displays all the attributes
+    git_url = project.ssh_url_to_repo
+    subprocess.call(['git', 'clone', git_url])
+
+Not all items are returned from the API
+"""""""""""""""""""""""""""""""""""""""
+
+If you've passed ``all=True`` to the API and still cannot see all items returned,
+use ``get_all=True`` (or ``--get-all`` via the CLI) instead. See :ref:`pagination` for more details.
+
+Common errors
+-------------
+
+.. _attribute_error_list:
+
+``AttributeError`` when accessing object attributes retrieved via ``list()``
+""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""
+
+Fetching a list of objects does not always include all attributes in the objects.
+To retrieve an object with all attributes, use a ``get()`` call.
+
+Example with projects::
+
+    for project in gl.projects.list(iterator=True):
+        # Retrieve project object with all attributes
+        project = gl.projects.get(project.id)
+
+``AttributeError`` when accessing attributes after ``save()`` or ``refresh()``
+""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""
+
+You are most likely trying to access an attribute that was not returned
+by the server on the second request. Please look at the documentation in
+:ref:`object_attributes` to see how to avoid this.
+
+``TypeError`` when accessing object attributes
+""""""""""""""""""""""""""""""""""""""""""""""
+
+When you encounter errors such as ``object is not iterable`` or ``object is not subscriptable``
+when trying to access object attributes returned from the server, you are most likely trying to
+access an attribute that is shadowed by python-gitlab's own methods or managers.
+
+You can use the object's ``attributes`` dictionary to access it directly instead.
+See the :ref:`objects` section for more details on how attributes are exposed.
+
+.. _conflicting_parameters_faq:
+
+I cannot use the parameter ``path`` (or some other parameter) as it conflicts with the library
+""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""
+
+``path`` is used by the python-gitlab library and cannot be used as a parameter
+if wanting to send it to the GitLab instance.  You can use the
+``query_parameters`` argument to send arguments that would conflict with python
+or python-gitlab when using them as kwargs:
+
+.. code-block:: python
+
+   ## invalid, as ``path`` is interpreted by python-gitlab as the Path or full
+   ## URL to query ('/projects' or 'http://whatever/v4/api/projects')
+   project.commits.list(path='some_file_path', iterator=True)
+
+   project.commits.list(query_parameters={'path': 'some_file_path'}, iterator=True)  # OK
+
+See :ref:`Conflicting Parameters <conflicting_parameters>` for more information.
diff --git a/docs/gl_objects/access_requests.py b/docs/gl_objects/access_requests.py
deleted file mode 100644
index 2a8c557c5..000000000
--- a/docs/gl_objects/access_requests.py
+++ /dev/null
@@ -1,38 +0,0 @@
-# list
-p_ars = gl.project_accessrequests.list(project_id=1)
-g_ars = gl.group_accessrequests.list(group_id=1)
-# or
-p_ars = project.accessrequests.list()
-g_ars = group.accessrequests.list()
-# end list
-
-# get
-p_ar = gl.project_accessrequests.get(user_id, project_id=1)
-g_ar = gl.group_accessrequests.get(user_id, group_id=1)
-# or
-p_ar = project.accessrequests.get(user_id)
-g_ar = group.accessrequests.get(user_id)
-# end get
-
-# create
-p_ar = gl.project_accessrequests.create({}, project_id=1)
-g_ar = gl.group_accessrequests.create({}, group_id=1)
-# or
-p_ar = project.accessrequests.create({})
-g_ar = group.accessrequests.create({})
-# end create
-
-# approve
-ar.approve()  # defaults to DEVELOPER level
-ar.approve(access_level=gitlab.MASTER_ACCESS)  # explicitly set access level
-# approve
-
-# delete
-gl.project_accessrequests.delete(user_id, project_id=1)
-gl.group_accessrequests.delete(user_id, group_id=1)
-# or
-project.accessrequests.delete(user_id)
-group.accessrequests.delete(user_id)
-# or
-ar.delete()
-# end delete
diff --git a/docs/gl_objects/access_requests.rst b/docs/gl_objects/access_requests.rst
index a9e6d9b98..c997fe0d7 100644
--- a/docs/gl_objects/access_requests.rst
+++ b/docs/gl_objects/access_requests.rst
@@ -2,44 +2,52 @@
 Access requests
 ###############
 
-Use :class:`~gitlab.objects.ProjectAccessRequest` and
-:class:`~gitlab.objects.GroupAccessRequest` objects to manipulate access
-requests for projects and groups. The
-:attr:`gitlab.Gitlab.project_accessrequests`,
-:attr:`gitlab.Gitlab.group_accessrequests`, :attr:`Project.accessrequests
-<gitlab.objects.Project.accessrequests>` and :attr:`Group.accessrequests
-<gitlab.objects.Group.accessrequests>` manager objects provide helper
-functions.
+Users can request access to groups and projects.
 
-Examples
---------
+When access is granted the user should be given a numerical access level. The
+following constants are provided to represent the access levels:
+
+* ``gitlab.const.AccessLevel.GUEST``: ``10``
+* ``gitlab.const.AccessLevel.REPORTER``: ``20``
+* ``gitlab.const.AccessLevel.DEVELOPER``: ``30``
+* ``gitlab.const.AccessLevel.MAINTAINER``: ``40``
+* ``gitlab.const.AccessLevel.OWNER``: ``50``
+
+References
+----------
 
-List access requests from projects and groups:
+* v4 API:
 
-.. literalinclude:: access_requests.py
-   :start-after: # list
-   :end-before: # end list
+  + :class:`gitlab.v4.objects.ProjectAccessRequest`
+  + :class:`gitlab.v4.objects.ProjectAccessRequestManager`
+  + :attr:`gitlab.v4.objects.Project.accessrequests`
+  + :class:`gitlab.v4.objects.GroupAccessRequest`
+  + :class:`gitlab.v4.objects.GroupAccessRequestManager`
+  + :attr:`gitlab.v4.objects.Group.accessrequests`
+
+* GitLab API: https://docs.gitlab.com/api/access_requests
+
+Examples
+--------
 
-Get a single request:
+List access requests from projects and groups::
 
-.. literalinclude:: access_requests.py
-   :start-after: # get
-   :end-before: # end get
+    p_ars = project.accessrequests.list(get_all=True)
+    g_ars = group.accessrequests.list(get_all=True)
 
-Create an access request:
+Create an access request::
 
-.. literalinclude:: access_requests.py
-   :start-after: # create
-   :end-before: # end create
+    p_ar = project.accessrequests.create()
+    g_ar = group.accessrequests.create()
 
-Approve an access request:
+Approve an access request::
 
-.. literalinclude:: access_requests.py
-   :start-after: # approve
-   :end-before: # end approve
+    ar.approve()  # defaults to DEVELOPER level
+    ar.approve(access_level=gitlab.const.AccessLevel.MAINTAINER)  # explicitly set access level
 
-Deny (delete) an access request:
+Deny (delete) an access request::
 
-.. literalinclude:: access_requests.py
-   :start-after: # delete
-   :end-before: # end delete
+    project.accessrequests.delete(user_id)
+    group.accessrequests.delete(user_id)
+    # or
+    ar.delete()
diff --git a/docs/gl_objects/appearance.rst b/docs/gl_objects/appearance.rst
new file mode 100644
index 000000000..611413d73
--- /dev/null
+++ b/docs/gl_objects/appearance.rst
@@ -0,0 +1,26 @@
+##########
+Appearance
+##########
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ApplicationAppearance`
+  + :class:`gitlab.v4.objects.ApplicationAppearanceManager`
+  + :attr:`gitlab.Gitlab.appearance`
+
+* GitLab API: https://docs.gitlab.com/api/appearance
+
+Examples
+--------
+
+Get the appearance::
+
+    appearance = gl.appearance.get()
+
+Update the appearance::
+
+    appearance.title = "Test"
+    appearance.save()
diff --git a/docs/gl_objects/applications.rst b/docs/gl_objects/applications.rst
new file mode 100644
index 000000000..fea051b25
--- /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/api/applications
+
+Examples
+--------
+
+List all OAuth applications::
+
+    applications = gl.applications.list(get_all=True)
+
+Create an application::
+
+    gl.applications.create({'name': 'your_app', 'redirect_uri': 'http://application.url', 'scopes': 'read_user openid profile email'})
+
+Delete an applications::
+
+    gl.applications.delete(app_id)
+    # or
+    application.delete()
diff --git a/docs/gl_objects/badges.rst b/docs/gl_objects/badges.rst
new file mode 100644
index 000000000..c84308032
--- /dev/null
+++ b/docs/gl_objects/badges.rst
@@ -0,0 +1,53 @@
+######
+Badges
+######
+
+Badges can be associated with groups and projects.
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.GroupBadge`
+  + :class:`gitlab.v4.objects.GroupBadgeManager`
+  + :attr:`gitlab.v4.objects.Group.badges`
+  + :class:`gitlab.v4.objects.ProjectBadge`
+  + :class:`gitlab.v4.objects.ProjectBadgeManager`
+  + :attr:`gitlab.v4.objects.Project.badges`
+
+* GitLab API:
+
+  + https://docs.gitlab.com/api/group_badges
+  + https://docs.gitlab.com/api/project_badges
+
+Examples
+--------
+
+List badges::
+
+    badges = group_or_project.badges.list(get_all=True)
+
+Get a badge::
+
+    badge = group_or_project.badges.get(badge_id)
+
+Create a badge::
+
+    badge = group_or_project.badges.create({'link_url': link, 'image_url': image_link})
+
+Update a badge::
+
+    badge.image_url = new_image_url
+    badge.link_url = new_link_url
+    badge.save()
+
+Delete a badge::
+
+    badge.delete()
+
+Render a badge (preview the generate URLs)::
+
+    output = group_or_project.badges.render(link, image_link)
+    print(output['rendered_link_url'])
+    print(output['rendered_image_url'])
diff --git a/docs/gl_objects/boards.rst b/docs/gl_objects/boards.rst
new file mode 100644
index 000000000..5031e4bd5
--- /dev/null
+++ b/docs/gl_objects/boards.rst
@@ -0,0 +1,104 @@
+############
+Issue boards
+############
+
+Boards
+======
+
+Boards are a visual representation of existing issues for a project or a group.
+Issues can be moved from one list to the other to track progress and help with
+priorities.
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectBoard`
+  + :class:`gitlab.v4.objects.ProjectBoardManager`
+  + :attr:`gitlab.v4.objects.Project.boards`
+  + :class:`gitlab.v4.objects.GroupBoard`
+  + :class:`gitlab.v4.objects.GroupBoardManager`
+  + :attr:`gitlab.v4.objects.Group.boards`
+
+* GitLab API:
+
+  + https://docs.gitlab.com/api/boards
+  + https://docs.gitlab.com/api/group_boards
+
+Examples
+--------
+
+Get the list of existing boards for a project or a group::
+
+    # item is a Project or a Group
+    boards = project_or_group.boards.list(get_all=True)
+
+Get a single board for a project or a group::
+
+    board = project_or_group.boards.get(board_id)
+
+Create a board::
+
+    board = project_or_group.boards.create({'name': 'new-board'})
+
+.. note:: Board creation is not supported in the GitLab CE edition.
+
+Delete a board::
+
+    board.delete()
+    # or
+    project_or_group.boards.delete(board_id)
+
+.. note:: Board deletion is not supported in the GitLab CE edition.
+
+Board lists
+===========
+
+Boards are made of lists of issues. Each list is associated to a label, and
+issues tagged with this label automatically belong to the list.
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectBoardList`
+  + :class:`gitlab.v4.objects.ProjectBoardListManager`
+  + :attr:`gitlab.v4.objects.ProjectBoard.lists`
+  + :class:`gitlab.v4.objects.GroupBoardList`
+  + :class:`gitlab.v4.objects.GroupBoardListManager`
+  + :attr:`gitlab.v4.objects.GroupBoard.lists`
+
+* GitLab API:
+
+  + https://docs.gitlab.com/api/boards
+  + https://docs.gitlab.com/api/group_boards
+
+Examples
+--------
+
+List the issue lists for a board::
+
+    b_lists = board.lists.list(get_all=True)
+
+Get a single list::
+
+    b_list = board.lists.get(list_id)
+
+Create a new list::
+
+    # First get a ProjectLabel
+    label = get_or_create_label()
+    # Then use its ID to create the new board list
+    b_list = board.lists.create({'label_id': label.id})
+
+Change a list position. The first list is at position 0. Moving a list will
+set it at the given position and move the following lists up a position::
+
+    b_list.position = 2
+    b_list.save()
+
+Delete a list::
+
+    b_list.delete()
diff --git a/docs/gl_objects/branches.py b/docs/gl_objects/branches.py
deleted file mode 100644
index b485ee083..000000000
--- a/docs/gl_objects/branches.py
+++ /dev/null
@@ -1,33 +0,0 @@
-# list
-branches = gl.project_branches.list(project_id=1)
-# or
-branches = project.branches.list()
-# end list
-
-# get
-branch = gl.project_branches.get(project_id=1, id='master')
-# or
-branch = project.branches.get('master')
-# end get
-
-# create
-branch = gl.project_branches.create({'branch_name': 'feature1',
-                                     'ref': 'master'},
-                                    project_id=1)
-# or
-branch = project.branches.create({'branch_name': 'feature1',
-                                  'ref': 'master'})
-# end create
-
-# delete
-gl.project_branches.delete(project_id=1, id='feature1')
-# or
-project.branches.delete('feature1')
-# or
-branch.delete()
-# end delete
-
-# protect
-branch.protect()
-branch.unprotect()
-# end protect
diff --git a/docs/gl_objects/branches.rst b/docs/gl_objects/branches.rst
index 50b97a799..823d98b85 100644
--- a/docs/gl_objects/branches.rst
+++ b/docs/gl_objects/branches.rst
@@ -2,49 +2,41 @@
 Branches
 ########
 
-Use :class:`~gitlab.objects.ProjectBranch` objects to manipulate repository
-branches.
+References
+----------
 
-To create :class:`~gitlab.objects.ProjectBranch` objects use the
-:attr:`gitlab.Gitlab.project_branches` or :attr:`Project.branches
-<gitlab.objects.Project.branches>` managers.
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectBranch`
+  + :class:`gitlab.v4.objects.ProjectBranchManager`
+  + :attr:`gitlab.v4.objects.Project.branches`
+
+* GitLab API: https://docs.gitlab.com/api/branches
 
 Examples
-========
+--------
+
+Get the list of branches for a repository::
 
-Get the list of branches for a repository:
+    branches = project.branches.list(get_all=True)
 
-.. literalinclude:: branches.py
-   :start-after: # list
-   :end-before: # end list
+Get a single repository branch::
 
-Get a single repository branch:
+    branch = project.branches.get('main')
 
-.. literalinclude:: branches.py
-   :start-after: # get
-   :end-before: # end get
+Create a repository branch::
 
-Create a repository branch:
+    branch = project.branches.create({'branch': 'feature1',
+                                      'ref': 'main'})
 
-.. literalinclude:: branches.py
-   :start-after: # create
-   :end-before: # end create
+Delete a repository branch::
 
-Delete a repository branch:
+    project.branches.delete('feature1')
+    # or
+    branch.delete()
 
-.. literalinclude:: branches.py
-   :start-after: # delete
-   :end-before: # end delete
+Delete the merged branches for a project::
 
-Protect/unprotect a repository branch:
+    project.delete_merged_branches()
 
-.. literalinclude:: branches.py
-   :start-after: # protect
-   :end-before: # end protect
-   
-.. note::
-   
-   By default, developers will not be able to push or merge into
-   protected branches. This can be changed by passing ``developers_can_push``
-   or ``developers_can_merge`` like so: 
-   ``branch.protect(developers_can_push=False, developers_can_merge=True)``
+To manage protected branches, see :doc:`/gl_objects/protected_branches`.
diff --git a/docs/gl_objects/builds.py b/docs/gl_objects/builds.py
deleted file mode 100644
index 855b7c898..000000000
--- a/docs/gl_objects/builds.py
+++ /dev/null
@@ -1,118 +0,0 @@
-# var list
-variables = gl.project_variables.list(project_id=1)
-# or
-variables = project.variables.list()
-# end var list
-
-# var get
-var = gl.project_variables.get(var_key, project_id=1)
-# or
-var = project.variables.get(var_key)
-# end var get
-
-# var create
-var = gl.project_variables.create({'key': 'key1', 'value': 'value1'},
-                                  project_id=1)
-# or
-var = project.variables.create({'key': 'key1', 'value': 'value1'})
-# end var create
-
-# var update
-var.value = 'new_value'
-var.save()
-# end var update
-
-# var delete
-gl.project_variables.delete(var_key)
-# or
-project.variables.delete()
-# or
-var.delete()
-# end var delete
-
-# trigger list
-triggers = gl.project_triggers.list(project_id=1)
-# or
-triggers = project.triggers.list()
-# end trigger list
-
-# trigger get
-trigger = gl.project_triggers.get(trigger_token, project_id=1)
-# or
-trigger = project.triggers.get(trigger_token)
-# end trigger get
-
-# trigger create
-trigger = gl.project_triggers.create({}, project_id=1)
-# or
-trigger = project.triggers.create({})
-# end trigger create
-
-# trigger delete
-gl.project_triggers.delete(trigger_token)
-# or
-project.triggers.delete()
-# or
-trigger.delete()
-# end trigger delete
-
-# list
-builds = gl.project_builds.list(project_id=1)
-# or
-builds = project.builds.list()
-# end list
-
-# commit list
-commit = gl.project_commits.get(commit_sha, project_id=1)
-builds = commit.builds()
-# end commit list
-
-# get
-build = gl.project_builds.get(build_id, project_id=1)
-# or
-project.builds.get(build_id)
-# end get
-
-# artifacts
-build.artifacts()
-# end artifacts
-
-# stream artifacts
-class Foo(object):
-    def __init__(self):
-        self._fd = open('artifacts.zip', 'wb')
-
-    def __call__(self, chunk):
-        self._fd.write(chunk)
-
-target = Foo()
-build.artifacts(streamed=True, action=target)
-del(target)  # flushes data on disk
-# end stream artifacts
-
-# keep artifacts
-build.keep_artifacts()
-# end keep artifacts
-
-# trace
-build.trace()
-# end trace
-
-# retry
-build.cancel()
-build.retry()
-# end retry
-
-# erase
-build.erase()
-# end erase
-
-# play
-build.play()
-# end play
-
-# trigger run
-p = gl.projects.get(project_id)
-p.trigger_build('master', trigger_token,
-                {'extra_var1': 'foo', 'extra_var2': 'bar'})
-# end trigger run
diff --git a/docs/gl_objects/builds.rst b/docs/gl_objects/builds.rst
deleted file mode 100644
index b20ca77b7..000000000
--- a/docs/gl_objects/builds.rst
+++ /dev/null
@@ -1,178 +0,0 @@
-######
-Builds
-######
-
-Build triggers
-==============
-
-Build triggers provide a way to interact with the GitLab CI. Using a trigger a
-user or an application can run a new build for a specific commit.
-
-* Object class: :class:`~gitlab.objects.ProjectTrigger`
-* Manager objects: :attr:`gitlab.Gitlab.project_triggers`,
-  :attr:`Project.triggers <gitlab.objects.Project.triggers>`
-
-Examples
---------
-
-List triggers:
-
-.. literalinclude:: builds.py
-   :start-after: # trigger list
-   :end-before: # end trigger list
-
-Get a trigger:
-
-.. literalinclude:: builds.py
-   :start-after: # trigger get
-   :end-before: # end trigger get
-
-Create a trigger:
-
-.. literalinclude:: builds.py
-   :start-after: # trigger create
-   :end-before: # end trigger create
-
-Remove a trigger:
-
-.. literalinclude:: builds.py
-   :start-after: # trigger delete
-   :end-before: # end trigger delete
-
-Build variables
-===============
-
-You can associate variables to builds to modify the build script behavior.
-
-* Object class: :class:`~gitlab.objects.ProjectVariable`
-* Manager objects: :attr:`gitlab.Gitlab.project_variables`,
-  :attr:`gitlab.objects.Project.variables`
-
-Examples
---------
-
-List variables:
-
-.. literalinclude:: builds.py
-   :start-after: # var list
-   :end-before: # end var list
-
-Get a variable:
-
-.. literalinclude:: builds.py
-   :start-after: # var get
-   :end-before: # end var get
-
-Create a variable:
-
-.. literalinclude:: builds.py
-   :start-after: # var create
-   :end-before: # end var create
-
-Update a variable value:
-
-.. literalinclude:: builds.py
-   :start-after: # var update
-   :end-before: # end var update
-
-Remove a variable:
-
-.. literalinclude:: builds.py
-   :start-after: # var delete
-   :end-before: # end var delete
-
-Builds
-======
-
-Builds are associated to projects and commits. They provide information on the
-build that have been run, and methods to manipulate those builds.
-
-* Object class: :class:`~gitlab.objects.ProjectBuild`
-* Manager objects: :attr:`gitlab.Gitlab.project_builds`,
-  :attr:`gitlab.objects.Project.builds`
-
-Examples
---------
-
-Build are usually automatically triggered, but you can explicitly trigger a
-new build:
-
-Trigger a new build on a project:
-
-.. literalinclude:: builds.py
-   :start-after: # trigger run
-   :end-before: # end trigger run
-
-List builds for the project:
-
-.. literalinclude:: builds.py
-   :start-after: # list
-   :end-before: # end list
-
-To list builds for a specific commit, create a
-:class:`~gitlab.objects.ProjectCommit` object and use its
-:attr:`~gitlab.objects.ProjectCommit.builds` method:
-
-.. literalinclude:: builds.py
-   :start-after: # commit list
-   :end-before: # end commit list
-
-Get a build:
-
-.. literalinclude:: builds.py
-   :start-after: # get
-   :end-before: # end get
-
-Get a build artifacts:
-
-.. literalinclude:: builds.py
-   :start-after: # artifacts
-   :end-before: # end artifacts
-
-.. warning::
-
-   Artifacts are entirely stored in memory in this example.
-
-.. _streaming_example:
-
-You can download artifacts as a stream. Provide a callable to handle the
-stream:
-
-.. literalinclude:: builds.py
-   :start-after: # stream artifacts
-   :end-before: # end stream artifacts
-
-Mark a build artifact as kept when expiration is set:
-
-.. literalinclude:: builds.py
-   :start-after: # keep artifacts
-   :end-before: # end keep artifacts
-
-Get a build trace:
-
-.. literalinclude:: builds.py
-   :start-after: # trace
-   :end-before: # end trace
-
-.. warning::
-
-   Traces are entirely stored in memory unless you use the streaming feature.
-   See :ref:`the artifacts example <streaming_example>`.
-
-Cancel/retry a build:
-
-.. literalinclude:: builds.py
-   :start-after: # retry
-   :end-before: # end retry
-
-Play (trigger) a build:
-
-.. literalinclude:: builds.py
-   :start-after: # play
-   :end-before: # end play
-
-Erase a build (artifacts and trace):
-
-.. literalinclude:: builds.py
-   :start-after: # erase
-   :end-before: # end erase
diff --git a/docs/gl_objects/bulk_imports.rst b/docs/gl_objects/bulk_imports.rst
new file mode 100644
index 000000000..6b1458a13
--- /dev/null
+++ b/docs/gl_objects/bulk_imports.rst
@@ -0,0 +1,82 @@
+#########################
+Migrations (Bulk Imports)
+#########################
+
+References
+----------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.BulkImport`
+  + :class:`gitlab.v4.objects.BulkImportManager`
+  + :attr:`gitlab.Gitlab.bulk_imports`
+  + :class:`gitlab.v4.objects.BulkImportAllEntity`
+  + :class:`gitlab.v4.objects.BulkImportAllEntityManager`
+  + :attr:`gitlab.Gitlab.bulk_import_entities`
+  + :class:`gitlab.v4.objects.BulkImportEntity`
+  + :class:`gitlab.v4.objects.BulkImportEntityManager`
+  + :attr:`gitlab.v4.objects.BulkImport.entities`
+
+* GitLab API: https://docs.gitlab.com/api/bulk_imports
+
+Examples
+--------
+
+.. note::
+
+    Like the project/group imports and exports, this is an asynchronous operation and you
+    will need to refresh the state from the server to get an accurate migration status. See
+    :ref:`project_import_export` in the import/export section for more details and examples.
+
+Start a bulk import/migration of a group and wait for completion::
+
+    # Create the migration
+    configuration = {
+        "url": "https://gitlab.example.com",
+        "access_token": private_token,
+    }
+    entity = {
+        "source_full_path": "source_group",
+        "source_type": "group_entity",
+        "destination_slug": "imported-group",
+        "destination_namespace": "imported-namespace",
+    }
+    migration = gl.bulk_imports.create(
+        {
+            "configuration": configuration,
+            "entities": [entity],
+        }
+    )
+
+    # Wait for the 'finished' status
+    while migration.status != "finished":
+        time.sleep(1)
+        migration.refresh()
+
+List all migrations::
+
+    gl.bulk_imports.list(get_all=True)
+
+List the entities of all migrations::
+
+    gl.bulk_import_entities.list(get_all=True)
+
+Get a single migration by ID::
+
+    migration = gl.bulk_imports.get(123)
+
+List the entities of a single migration::
+
+    entities = migration.entities.list(get_all=True)
+
+Get a single entity of a migration by ID::
+
+    entity = migration.entities.get(123)
+
+Refresh the state of a migration or entity from the server::
+
+    migration.refresh()
+    entity.refresh()
+
+    print(migration.status)
+    print(entity.status)
diff --git a/docs/gl_objects/ci_lint.rst b/docs/gl_objects/ci_lint.rst
new file mode 100644
index 000000000..b44b09486
--- /dev/null
+++ b/docs/gl_objects/ci_lint.rst
@@ -0,0 +1,69 @@
+#######
+CI Lint
+#######
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.CiLint`
+  + :class:`gitlab.v4.objects.CiLintManager`
+  + :attr:`gitlab.Gitlab.ci_lint`
+  + :class:`gitlab.v4.objects.ProjectCiLint`
+  + :class:`gitlab.v4.objects.ProjectCiLintManager`
+  + :attr:`gitlab.v4.objects.Project.ci_lint`
+
+* GitLab API: https://docs.gitlab.com/api/lint
+
+Examples
+---------
+
+Lint a CI YAML configuration::
+
+    gitlab_ci_yml = """.api_test:
+      rules:
+        - if: $CI_PIPELINE_SOURCE=="merge_request_event"
+          changes:
+            - src/api/*
+    deploy:
+      extends:
+        - .api_test
+      rules:
+        - when: manual
+          allow_failure: true
+      script:
+        - echo "hello world"
+    """
+    lint_result = gl.ci_lint.create({"content": gitlab_ci_yml})
+
+    print(lint_result.status)  # Print the status of the CI YAML
+    print(lint_result.merged_yaml)  # Print the merged YAML file
+
+Lint a project's CI configuration::
+
+    lint_result = project.ci_lint.get()
+    assert lint_result.valid is True  # Test that the .gitlab-ci.yml is valid
+    print(lint_result.merged_yaml)    # Print the merged YAML file
+
+Lint a CI YAML configuration with a namespace::
+
+    lint_result = project.ci_lint.create({"content": gitlab_ci_yml})
+    assert lint_result.valid is True  # Test that the .gitlab-ci.yml is valid
+    print(lint_result.merged_yaml)    # Print the merged YAML file
+
+Validate a CI YAML configuration (raises ``GitlabCiLintError`` on failures)::
+
+    # returns None
+    gl.ci_lint.validate({"content": gitlab_ci_yml})
+
+    # raises GitlabCiLintError
+    gl.ci_lint.validate({"content": "invalid"})
+
+Validate a CI YAML configuration with a namespace::
+
+    # returns None
+    project.ci_lint.validate({"content": gitlab_ci_yml})
+
+    # raises GitlabCiLintError
+    project.ci_lint.validate({"content": "invalid"})
diff --git a/docs/gl_objects/cluster_agents.rst b/docs/gl_objects/cluster_agents.rst
new file mode 100644
index 000000000..b9810959d
--- /dev/null
+++ b/docs/gl_objects/cluster_agents.rst
@@ -0,0 +1,41 @@
+##############
+Cluster agents
+##############
+
+You can list and manage project cluster agents with the GitLab agent for Kubernetes.
+
+.. warning::
+   Check the GitLab API documentation linked below for project permissions
+   required to access specific cluster agent endpoints.
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectClusterAgent`
+  + :class:`gitlab.v4.objects.ProjectClusterAgentManager`
+  + :attr:`gitlab.v4.objects.Project.cluster_agents`
+
+* GitLab API: https://docs.gitlab.com/api/cluster_agents
+
+Examples
+--------
+
+List cluster agents for a project::
+
+    cluster_agents = project.cluster_agents.list(get_all=True)
+
+Register a cluster agent with a project::
+
+    cluster_agent = project.cluster_agents.create({"name": "Agent 1"})
+
+Retrieve a specific cluster agent for a project::
+
+    cluster_agent = project.cluster_agents.get(cluster_agent.id)
+
+Delete a registered cluster agent from a project::
+
+    cluster_agent = project.cluster_agents.delete(cluster_agent.id)
+    # or
+    cluster.delete()
diff --git a/docs/gl_objects/clusters.rst b/docs/gl_objects/clusters.rst
new file mode 100644
index 000000000..7cf413bc2
--- /dev/null
+++ b/docs/gl_objects/clusters.rst
@@ -0,0 +1,87 @@
+#####################
+Clusters (DEPRECATED) 
+#####################
+
+.. warning::
+   Cluster support was deprecated in GitLab 14.5 and disabled by default as of
+   GitLab 15.0
+
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectCluster`
+  + :class:`gitlab.v4.objects.ProjectClusterManager`
+  + :attr:`gitlab.v4.objects.Project.clusters`
+  + :class:`gitlab.v4.objects.GroupCluster`
+  + :class:`gitlab.v4.objects.GroupClusterManager`
+  + :attr:`gitlab.v4.objects.Group.clusters`
+
+* GitLab API: https://docs.gitlab.com/api/project_clusters
+* GitLab API: https://docs.gitlab.com/api/group_clusters
+
+Examples
+--------
+
+List clusters for a project::
+
+    clusters = project.clusters.list(get_all=True)
+
+Create an cluster for a project::
+
+    cluster = project.clusters.create(
+    {
+        "name": "cluster1",
+        "platform_kubernetes_attributes": {
+            "api_url": "http://url",
+            "token": "tokenval",
+        },
+    })
+
+Retrieve a specific cluster for a project::
+
+    cluster = project.clusters.get(cluster_id)
+
+Update an cluster for a project::
+
+    cluster.platform_kubernetes_attributes = {"api_url": "http://newurl"}
+    cluster.save()
+
+Delete an cluster for a project::
+
+    cluster = project.clusters.delete(cluster_id)
+    # or
+    cluster.delete()
+
+
+List clusters for a group::
+
+    clusters = group.clusters.list(get_all=True)
+
+Create an cluster for a group::
+
+    cluster = group.clusters.create(
+    {
+        "name": "cluster1",
+        "platform_kubernetes_attributes": {
+            "api_url": "http://url",
+            "token": "tokenval",
+        },
+    })
+
+Retrieve a specific cluster for a group::
+
+    cluster = group.clusters.get(cluster_id)
+
+Update an cluster for a group::
+
+    cluster.platform_kubernetes_attributes = {"api_url": "http://newurl"}
+    cluster.save()
+
+Delete an cluster for a group::
+
+    cluster = group.clusters.delete(cluster_id)
+    # or
+    cluster.delete()
diff --git a/docs/gl_objects/commits.py b/docs/gl_objects/commits.py
deleted file mode 100644
index 30465139e..000000000
--- a/docs/gl_objects/commits.py
+++ /dev/null
@@ -1,50 +0,0 @@
-# list
-commits = gl.project_commits.list(project_id=1)
-# or
-commits = project.commits.list()
-# end list
-
-# filter list
-commits = project.commits.list(ref_name='my_branch')
-commits = project.commits.list(since='2016-01-01T00:00:00Z')
-# end filter list
-
-# get
-commit = gl.project_commits.get('e3d5a71b', project_id=1)
-# or
-commit = project.commits.get('e3d5a71b')
-# end get
-
-# diff
-diff = commit.diff()
-# end diff
-
-# comments list
-comments = gl.project_commit_comments.list(project_id=1, commit_id='master')
-# or
-comments = project.commit_comments.list(commit_id='a5fe4c8')
-# or
-comments = commit.comments.list()
-# end comments list
-
-# comments create
-# Global comment
-commit = commit.comments.create({'note': 'This is a nice comment'})
-# Comment on a line in a file (on the new version of the file)
-commit = commit.comments.create({'note': 'This is another comment',
-                                 'line': 12,
-                                 'line_type': 'new',
-                                 'path': 'README.rst'})
-# end comments create
-
-# statuses list
-statuses = gl.project_commit_statuses.list(project_id=1, commit_id='master')
-# or
-statuses = project.commit_statuses.list(commit_id='a5fe4c8')
-# or
-statuses = commit.statuses.list()
-# end statuses list
-
-# statuses set
-commit.statuses.create({'state': 'success'})
-# end statuses set
diff --git a/docs/gl_objects/commits.rst b/docs/gl_objects/commits.rst
index 5a43597a5..0c612f3de 100644
--- a/docs/gl_objects/commits.rst
+++ b/docs/gl_objects/commits.rst
@@ -5,83 +5,139 @@ Commits
 Commits
 =======
 
-Use :class:`~gitlab.objects.ProjectCommit` objects to manipulate commits. The
-:attr:`gitlab.Gitlab.project_commits` and
-:attr:`gitlab.objects.Project.commits` manager objects provide helper
-functions.
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectCommit`
+  + :class:`gitlab.v4.objects.ProjectCommitManager`
+  + :attr:`gitlab.v4.objects.Project.commits`
 
 Examples
 --------
 
-List the commits for a project:
+List the commits for a project::
 
-.. literalinclude:: commits.py
-   :start-after: # list
-   :end-before: # end list
+    commits = project.commits.list(get_all=True)
 
 You can use the ``ref_name``, ``since`` and ``until`` filters to limit the
-results:
+results::
+
+    commits = project.commits.list(ref_name='my_branch', get_all=True)
+    commits = project.commits.list(since='2016-01-01T00:00:00Z', get_all=True)
+
+List all commits for a project (see :ref:`pagination`) on all branches:
+
+    commits = project.commits.list(get_all=True)
+
+Create a commit::
+
+    # See https://docs.gitlab.com/api/commits#create-a-commit-with-multiple-files-and-actions
+    # for actions detail
+    data = {
+        'branch': 'main',
+        'commit_message': 'blah blah blah',
+        'actions': [
+            {
+                'action': 'create',
+                'file_path': 'README.rst',
+                'content': open('path/to/file.rst').read(),
+            },
+            {
+                # Binary files need to be base64 encoded
+                'action': 'create',
+                'file_path': 'logo.png',
+                'content': base64.b64encode(open('logo.png', mode='r+b').read()).decode(),
+                'encoding': 'base64',
+            }
+        ]
+    }
+
+    commit = project.commits.create(data)
+
+Get a commit detail::
+
+    commit = project.commits.get('e3d5a71b')
+
+Get the diff for a commit::
+
+    diff = commit.diff()
 
-.. literalinclude:: commits.py
-   :start-after: # filter list
-   :end-before: # end filter list
+Cherry-pick a commit into another branch::
 
-Get a commit detail:
+    commit.cherry_pick(branch='target_branch')
 
-.. literalinclude:: commits.py
-   :start-after: # get
-   :end-before: # end get
+Revert a commit on a given branch::
 
-Get the diff for a commit:
+    commit.revert(branch='target_branch')
 
-.. literalinclude:: commits.py
-   :start-after: # diff
-   :end-before: # end diff
+Get the references the commit has been pushed to (branches and tags)::
+
+    commit.refs()  # all references
+    commit.refs('tag')  # only tags
+    commit.refs('branch')  # only branches
+
+Get the signature of the commit (if the commit was signed, e.g. with GPG or x509)::
+
+    commit.signature()
+
+List the merge requests related to a commit::
+
+    commit.merge_requests()
 
 Commit comments
 ===============
 
-Use :class:`~gitlab.objects.ProjectCommitStatus` objects to manipulate commits. The
-:attr:`gitlab.Gitlab.project_commit_comments` and
-:attr:`gitlab.objects.Project.commit_comments` and
-:attr:`gitlab.objects.ProjectCommit.comments` manager objects provide helper
-functions.
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectCommitComment`
+  + :class:`gitlab.v4.objects.ProjectCommitCommentManager`
+  + :attr:`gitlab.v4.objects.ProjectCommit.comments`
+
+* GitLab API: https://docs.gitlab.com/api/commits
 
 Examples
 --------
 
-Get the comments for a commit:
+Get the comments for a commit::
 
-.. literalinclude:: commits.py
-   :start-after: # comments list
-   :end-before: # end comments list
+    comments = commit.comments.list(get_all=True)
 
-Add a comment on a commit:
+Add a comment on a commit::
 
-.. literalinclude:: commits.py
-   :start-after: # comments create
-   :end-before: # end comments create
+    # Global comment
+    commit = commit.comments.create({'note': 'This is a nice comment'})
+    # Comment on a line in a file (on the new version of the file)
+    commit = commit.comments.create({'note': 'This is another comment',
+                                     'line': 12,
+                                     'line_type': 'new',
+                                     'path': 'README.rst'})
 
 Commit status
 =============
 
-Use :class:`~gitlab.objects.ProjectCommitStatus` objects to manipulate commits.
-The :attr:`gitlab.Gitlab.project_commit_statuses`,
-:attr:`gitlab.objects.Project.commit_statuses` and
-:attr:`gitlab.objects.ProjectCommit.statuses` manager objects provide helper
-functions.
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectCommitStatus`
+  + :class:`gitlab.v4.objects.ProjectCommitStatusManager`
+  + :attr:`gitlab.v4.objects.ProjectCommit.statuses`
+
+* GitLab API: https://docs.gitlab.com/api/commits
 
 Examples
 --------
 
-Get the statuses for a commit:
+List the statuses for a commit::
 
-.. literalinclude:: commits.py
-   :start-after: # statuses list
-   :end-before: # end statuses list
+    statuses = commit.statuses.list(get_all=True)
 
-Change the status of a commit:
+Change the status of a commit::
 
-.. literalinclude:: commits.py
-   :start-after: # statuses set
-   :end-before: # end statuses set
+    commit.statuses.create({'state': 'success'})
diff --git a/docs/gl_objects/deploy_keys.py b/docs/gl_objects/deploy_keys.py
deleted file mode 100644
index f144d9ef9..000000000
--- a/docs/gl_objects/deploy_keys.py
+++ /dev/null
@@ -1,44 +0,0 @@
-# global list
-keys = gl.keys.list()
-# end global list
-
-# global get
-key = gl.keys.get(key_id)
-# end global key
-
-# list
-keys = gl.project_keys.list(project_id=1)
-# or
-keys = project.keys.list()
-# end list
-
-# get
-key = gl.project_keys.get(key_id, project_id=1)
-# or
-key = project.keys.get(key_id)
-# end get
-
-# create
-key = gl.project_keys.create({'title': 'jenkins key',
-                              'key': open('/home/me/.ssh/id_rsa.pub').read()},
-                             project_id=1)
-# or
-key = project.keys.create({'title': 'jenkins key',
-                           'key': open('/home/me/.ssh/id_rsa.pub').read()})
-# end create
-
-# delete
-key = gl.project_keys.delete(key_id, project_id=1)
-# or
-key = project.keys.list(key_id)
-# or
-key.delete()
-# end delete
-
-# enable
-deploy_key.enable()
-# end enable
-
-# disable
-deploy_key.disable()
-# end disable
diff --git a/docs/gl_objects/deploy_keys.rst b/docs/gl_objects/deploy_keys.rst
index 57c129848..9f91fea0f 100644
--- a/docs/gl_objects/deploy_keys.rst
+++ b/docs/gl_objects/deploy_keys.rst
@@ -5,66 +5,70 @@ Deploy keys
 Deploy keys
 ===========
 
-Use :class:`~gitlab.objects.Key` objects to manipulate deploy keys. The
-:attr:`gitlab.Gitlab.keys` manager object provides helper functions.
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.DeployKey`
+  + :class:`gitlab.v4.objects.DeployKeyManager`
+  + :attr:`gitlab.Gitlab.deploykeys`
+
+* GitLab API: https://docs.gitlab.com/api/deploy_keys
 
 Examples
 --------
 
-List the deploy keys:
+Add an instance-wide deploy key (requires admin access)::
 
-.. literalinclude:: deploy_keys.py
-   :start-after: # global list
-   :end-before: # end global list
+    keys = gl.deploykeys.create({'title': 'instance key', 'key': INSTANCE_KEY})
 
-Get a single deploy key:
+List all deploy keys::
 
-.. literalinclude:: deploy_keys.py
-   :start-after: # global get
-   :end-before: # end global get
+    keys = gl.deploykeys.list(get_all=True)
 
 Deploy keys for projects
 ========================
 
-Use :class:`~gitlab.objects.ProjectKey` objects to manipulate deploy keys for
-projects. The :attr:`gitlab.Gitlab.project_keys` and :attr:`Project.keys
-<gitlab.objects.Project.keys>` manager objects provide helper functions.
+Deploy keys can be managed on a per-project basis.
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectKey`
+  + :class:`gitlab.v4.objects.ProjectKeyManager`
+  + :attr:`gitlab.v4.objects.Project.keys`
+
+* GitLab API: https://docs.gitlab.com/api/deploy_keys
 
 Examples
 --------
 
-List keys for a project:
+List keys for a project::
 
-.. literalinclude:: deploy_keys.py
-   :start-after: # list
-   :end-before: # end list
+    keys = project.keys.list(get_all=True)
 
-Get a single deploy key:
+Get a single deploy key::
 
-.. literalinclude:: deploy_keys.py
-   :start-after: # get
-   :end-before: # end get
+    key = project.keys.get(key_id)
 
-Create a deploy key for a project:
+Create a deploy key for a project::
 
-.. literalinclude:: deploy_keys.py
-   :start-after: # create
-   :end-before: # end create
+    key = project.keys.create({'title': 'jenkins key',
+                               'key': open('/home/me/.ssh/id_rsa.pub').read()})
 
-Delete a deploy key for a project:
+Delete a deploy key for a project::
 
-.. literalinclude:: deploy_keys.py
-   :start-after: # delete
-   :end-before: # end delete
+    key = project.keys.list(key_id, get_all=True)
+    # or
+    key.delete()
 
-Enable a deploy key for a project:
+Enable a deploy key for a project::
 
-.. literalinclude:: deploy_keys.py
-   :start-after: # enable
-   :end-before: # end enable
+    project.keys.enable(key_id)
 
-Disable a deploy key for a project:
+Disable a deploy key for a project::
 
-.. literalinclude:: deploy_keys.py
-   :start-after: # disable
-   :end-before: # end disable
+    project.keys.delete(key_id)
diff --git a/docs/gl_objects/deploy_tokens.rst b/docs/gl_objects/deploy_tokens.rst
new file mode 100644
index 000000000..80c00803a
--- /dev/null
+++ b/docs/gl_objects/deploy_tokens.rst
@@ -0,0 +1,145 @@
+#############
+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/api/deploy_tokens
+
+Examples
+--------
+
+Use the ``list()`` method to list all deploy tokens across the GitLab instance.
+
+::
+
+    # List deploy tokens
+    deploy_tokens = gl.deploytokens.list(get_all=True)
+
+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/api/deploy_tokens#project-deploy-tokens
+
+Examples
+--------
+
+List the deploy tokens for a project::
+
+    deploy_tokens = project.deploytokens.list(get_all=True)
+
+Get a deploy token for a project by id::
+
+    deploy_token = project.deploytokens.get(deploy_token_id)
+
+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 overridden 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/api/deploy_tokens#group-deploy-tokens
+
+Examples
+--------
+
+List the deploy tokens for a group::
+
+    deploy_tokens = group.deploytokens.list(get_all=True)
+
+Get a deploy token for a group by id::
+
+    deploy_token = group.deploytokens.get(deploy_token_id)
+
+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 overridden 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/deployments.py b/docs/gl_objects/deployments.py
deleted file mode 100644
index fe1613a15..000000000
--- a/docs/gl_objects/deployments.py
+++ /dev/null
@@ -1,11 +0,0 @@
-# list
-deployments = gl.project_deployments.list(project_id=1)
-# or
-deployments = project.deployments.list()
-# end list
-
-# get
-deployment = gl.project_deployments.get(deployment_id, project_id=1)
-# or
-deployment = project.deployments.get(deployment_id)
-# end get
diff --git a/docs/gl_objects/deployments.rst b/docs/gl_objects/deployments.rst
index 1a679da51..4be927af7 100644
--- a/docs/gl_objects/deployments.rst
+++ b/docs/gl_objects/deployments.rst
@@ -2,22 +2,74 @@
 Deployments
 ###########
 
-Use :class:`~gitlab.objects.ProjectDeployment` objects to manipulate project
-deployments. The :attr:`gitlab.Gitlab.project_deployments`, and
-:attr:`Project.deployments <gitlab.objects.Project.deployments>` manager
-objects provide helper functions.
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectDeployment`
+  + :class:`gitlab.v4.objects.ProjectDeploymentManager`
+  + :attr:`gitlab.v4.objects.Project.deployments`
+
+* GitLab API: https://docs.gitlab.com/api/deployments
 
 Examples
 --------
 
-List deployments for a project:
+List deployments for a project::
+
+    deployments = project.deployments.list(get_all=True)
+
+Get a single deployment::
+
+    deployment = project.deployments.get(deployment_id)
+
+Create a new deployment::
+
+    deployment = project.deployments.create({
+        "environment": "Test",
+        "sha": "1agf4gs",
+        "ref": "main",
+        "tag": False,
+        "status": "created",
+    })
+
+Update a deployment::
+
+    deployment = project.deployments.get(42)
+    deployment.status = "failed"
+    deployment.save()
+
+Approve a deployment::
 
-.. literalinclude:: deployments.py
-   :start-after: # list
-   :end-before: # end list
+    deployment = project.deployments.get(42)
+    # `status` must be either "approved" or "rejected".
+    deployment.approval(status="approved")
+
+Reject a deployment::
+
+    deployment = project.deployments.get(42)
+    # Using the optional `comment` and `represented_as` arguments
+    deployment.approval(status="rejected", comment="Fails CI", represented_as="security")
+
+Merge requests associated with a deployment
+===========================================
+
+Reference
+----------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectDeploymentMergeRequest`
+  + :class:`gitlab.v4.objects.ProjectDeploymentMergeRequestManager`
+  + :attr:`gitlab.v4.objects.ProjectDeployment.mergerequests`
+
+* GitLab API: https://docs.gitlab.com/api/deployments#list-of-merge-requests-associated-with-a-deployment
+
+Examples
+--------
 
-Get a single deployment:
+List the merge requests associated with a deployment::
 
-.. literalinclude:: deployments.py
-   :start-after: # get
-   :end-before: # end get
+    deployment = project.deployments.get(42, lazy=True)
+    mrs = deployment.mergerequests.list(get_all=True)
diff --git a/docs/gl_objects/discussions.rst b/docs/gl_objects/discussions.rst
new file mode 100644
index 000000000..f64a98b3d
--- /dev/null
+++ b/docs/gl_objects/discussions.rst
@@ -0,0 +1,107 @@
+###########
+Discussions
+###########
+
+Discussions organize the notes in threads. See the :ref:`project-notes` chapter
+for more information about notes.
+
+Discussions are available for project issues, merge requests, snippets and
+commits.
+
+Reference
+=========
+
+* v4 API:
+
+  Issues:
+
+  + :class:`gitlab.v4.objects.ProjectIssueDiscussion`
+  + :class:`gitlab.v4.objects.ProjectIssueDiscussionManager`
+  + :class:`gitlab.v4.objects.ProjectIssueDiscussionNote`
+  + :class:`gitlab.v4.objects.ProjectIssueDiscussionNoteManager`
+  + :attr:`gitlab.v4.objects.ProjectIssue.notes`
+
+  MergeRequests:
+
+  + :class:`gitlab.v4.objects.ProjectMergeRequestDiscussion`
+  + :class:`gitlab.v4.objects.ProjectMergeRequestDiscussionManager`
+  + :class:`gitlab.v4.objects.ProjectMergeRequestDiscussionNote`
+  + :class:`gitlab.v4.objects.ProjectMergeRequestDiscussionNoteManager`
+  + :attr:`gitlab.v4.objects.ProjectMergeRequest.notes`
+
+  Snippets:
+
+  + :class:`gitlab.v4.objects.ProjectSnippetDiscussion`
+  + :class:`gitlab.v4.objects.ProjectSnippetDiscussionManager`
+  + :class:`gitlab.v4.objects.ProjectSnippetDiscussionNote`
+  + :class:`gitlab.v4.objects.ProjectSnippetDiscussionNoteManager`
+  + :attr:`gitlab.v4.objects.ProjectSnippet.notes`
+
+* GitLab API: https://docs.gitlab.com/api/discussions
+
+Examples
+========
+
+List the discussions for a resource (issue, merge request, snippet or commit)::
+
+    discussions = resource.discussions.list(get_all=True)
+
+Get a single discussion::
+
+    discussion = resource.discussions.get(discussion_id)
+
+You can access the individual notes in the discussion through the ``notes``
+attribute. It holds a list of notes in chronological order::
+
+    # ``resource.notes`` is a DiscussionNoteManager, so we need to get the
+    # object notes using ``attributes``
+    for note in discussion.attributes['notes']:
+        print(note['body'])
+
+.. note::
+
+   The notes are dicts, not objects.
+
+You can add notes to existing discussions::
+
+    new_note = discussion.notes.create({'body': 'Episode IV: A new note'})
+
+You can get and update a single note using the ``*DiscussionNote`` resources::
+
+    discussion = resource.discussions.get(discussion_id)
+    # Get the latest note's id
+    note_id = discussion.attributes['notes'][-1]['id']
+    last_note = discussion.notes.get(note_id)
+    last_note.body = 'Updated comment'
+    last_note.save()
+
+Create a new discussion::
+
+    discussion = resource.discussions.create({'body': 'First comment of discussion'})
+
+You can comment on merge requests and commit diffs. Provide the ``position``
+dict to define where the comment should appear in the diff::
+
+    mr_diff = mr.diffs.get(diff_id)
+    mr.discussions.create({'body': 'Note content',
+                           'position': {
+                               'base_sha': mr_diff.base_commit_sha,
+                               'start_sha': mr_diff.start_commit_sha,
+                               'head_sha': mr_diff.head_commit_sha,
+                               'position_type': 'text',
+                               'new_line': 1,
+                               'old_path': 'README.rst',
+                               'new_path': 'README.rst'}
+                           })
+
+Resolve / unresolve a merge request discussion::
+
+    mr_d = mr.discussions.get(d_id)
+    mr_d.resolved = True  # True to resolve, False to unresolve
+    mr_d.save()
+
+Delete a comment::
+
+    discussions.notes.delete(note_id)
+    # or
+    note.delete()
diff --git a/docs/gl_objects/draft_notes.rst b/docs/gl_objects/draft_notes.rst
new file mode 100644
index 000000000..8f33de6e6
--- /dev/null
+++ b/docs/gl_objects/draft_notes.rst
@@ -0,0 +1,58 @@
+.. _draft-notes:
+
+###########
+Draft Notes
+###########
+
+Draft notes are pending, unpublished comments on merge requests.
+They can be either start a discussion, or be associated with an existing discussion as a reply.
+They are viewable only by the author until they are published. 
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectMergeRequestDraftNote`
+  + :class:`gitlab.v4.objects.ProjectMergeRequestDraftNoteManager`
+  + :attr:`gitlab.v4.objects.ProjectMergeRequest.draft_notes`
+
+
+* GitLab API: https://docs.gitlab.com/api/draft_notes
+
+Examples
+--------
+
+List all draft notes for a merge request::
+
+    draft_notes = merge_request.draft_notes.list(get_all=True)
+
+Get a draft note for a merge request by ID::
+
+    draft_note = merge_request.draft_notes.get(note_id)
+
+.. warning::
+
+   When creating or updating draft notes, you can provide a complex nested ``position`` argument as a dictionary.
+   Please consult the upstream API documentation linked above for the exact up-to-date attributes.
+
+Create a draft note for a merge request::
+
+    draft_note = merge_request.draft_notes.create({'note': 'note content'})
+
+Update an existing draft note::
+
+    draft_note.note = 'updated note content'
+    draft_note.save()
+
+Delete an existing draft note::
+
+    draft_note.delete()
+
+Publish an existing draft note::
+
+    draft_note.publish()
+
+Publish all existing draft notes for a merge request in bulk::
+
+    merge_request.draft_notes.bulk_publish()
diff --git a/docs/gl_objects/emojis.rst b/docs/gl_objects/emojis.rst
new file mode 100644
index 000000000..1675916e1
--- /dev/null
+++ b/docs/gl_objects/emojis.rst
@@ -0,0 +1,45 @@
+############
+Award Emojis
+############
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectIssueAwardEmoji`
+  + :class:`gitlab.v4.objects.ProjectIssueNoteAwardEmoji`
+  + :class:`gitlab.v4.objects.ProjectMergeRequestAwardEmoji`
+  + :class:`gitlab.v4.objects.ProjectMergeRequestNoteAwardEmoji`
+  + :class:`gitlab.v4.objects.ProjectSnippetAwardEmoji`
+  + :class:`gitlab.v4.objects.ProjectSnippetNoteAwardEmoji`
+  + :class:`gitlab.v4.objects.ProjectIssueAwardEmojiManager`
+  + :class:`gitlab.v4.objects.ProjectIssueNoteAwardEmojiManager`
+  + :class:`gitlab.v4.objects.ProjectMergeRequestAwardEmojiManager`
+  + :class:`gitlab.v4.objects.ProjectMergeRequestNoteAwardEmojiManager`
+  + :class:`gitlab.v4.objects.ProjectSnippetAwardEmojiManager`
+  + :class:`gitlab.v4.objects.ProjectSnippetNoteAwardEmojiManager`
+
+
+* GitLab API: https://docs.gitlab.com/api/emoji_reactions/
+
+Examples
+--------
+
+List emojis for a resource::
+
+   emojis = obj.awardemojis.list(get_all=True)
+
+Get a single emoji::
+
+   emoji = obj.awardemojis.get(emoji_id)
+
+Add (create) an emoji::
+
+   emoji = obj.awardemojis.create({'name': 'tractor'})
+
+Delete an emoji::
+
+   emoji.delete
+   # or
+   obj.awardemojis.delete(emoji_id)
diff --git a/docs/gl_objects/environments.py b/docs/gl_objects/environments.py
deleted file mode 100644
index 80d77c922..000000000
--- a/docs/gl_objects/environments.py
+++ /dev/null
@@ -1,31 +0,0 @@
-# list
-environments = gl.project_environments.list(project_id=1)
-# or
-environments = project.environments.list()
-# end list
-
-# get
-environment = gl.project_environments.get(environment_id, project_id=1)
-# or
-environment = project.environments.get(environment_id)
-# end get
-
-# create
-environment = gl.project_environments.create({'name': 'production'},
-                                             project_id=1)
-# or
-environment = project.environments.create({'name': 'production'})
-# end create
-
-# update
-environment.external_url = 'http://foo.bar.com'
-environment.save()
-# end update
-
-# delete
-environment = gl.project_environments.delete(environment_id, project_id=1)
-# or
-environment = project.environments.list(environment_id)
-# or
-environment.delete()
-# end delete
diff --git a/docs/gl_objects/environments.rst b/docs/gl_objects/environments.rst
index 83d080b5c..382820b76 100644
--- a/docs/gl_objects/environments.rst
+++ b/docs/gl_objects/environments.rst
@@ -2,40 +2,45 @@
 Environments
 ############
 
-Use :class:`~gitlab.objects.ProjectEnvironment` objects to manipulate
-environments for projects. The :attr:`gitlab.Gitlab.project_environments` and
-:attr:`Project.environments <gitlab.objects.Project.environments>` manager
-objects provide helper functions.
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectEnvironment`
+  + :class:`gitlab.v4.objects.ProjectEnvironmentManager`
+  + :attr:`gitlab.v4.objects.Project.environments`
+
+* GitLab API: https://docs.gitlab.com/api/environments
 
 Examples
 --------
 
-List environments for a project:
+List environments for a project::
+
+    environments = project.environments.list(get_all=True)
+
+Create an environment for a project::
+
+    environment = project.environments.create({'name': 'production'})
 
-.. literalinclude:: environments.py
-   :start-after: # list
-   :end-before: # end list
+Retrieve a specific environment for a project::
 
-Get a single environment:
+    environment = project.environments.get(112)
 
-.. literalinclude:: environments.py
-   :start-after: # get
-   :end-before: # end get
+Update an environment for a project::
 
-Create an environment for a project:
+    environment.external_url = 'http://foo.bar.com'
+    environment.save()
 
-.. literalinclude:: environments.py
-   :start-after: # create
-   :end-before: # end create
+Delete an environment for a project::
 
-Update an environment for a project:
+    environment = project.environments.delete(environment_id)
+    # or
+    environment.delete()
 
-.. literalinclude:: environments.py
-   :start-after: # update
-   :end-before: # end update
+Stop an environment::
 
-Delete an environment for a project:
+    environment.stop()
 
-.. literalinclude:: environments.py
-   :start-after: # delete
-   :end-before: # end delete
+To manage protected environments, see :doc:`/gl_objects/protected_environments`.
diff --git a/docs/gl_objects/epics.rst b/docs/gl_objects/epics.rst
new file mode 100644
index 000000000..7e43aaa8e
--- /dev/null
+++ b/docs/gl_objects/epics.rst
@@ -0,0 +1,79 @@
+#####
+Epics
+#####
+
+Epics
+=====
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.GroupEpic`
+  + :class:`gitlab.v4.objects.GroupEpicManager`
+  + :attr:`gitlab.Gitlab.Group.epics`
+
+* GitLab API: https://docs.gitlab.com/api/epics (EE feature)
+
+Examples
+--------
+
+List the epics for a group::
+
+    epics = groups.epics.list(get_all=True)
+
+Get a single epic for a group::
+
+    epic = group.epics.get(epic_iid)
+
+Create an epic for a group::
+
+    epic = group.epics.create({'title': 'My Epic'})
+
+Edit an epic::
+
+    epic.title = 'New title'
+    epic.labels = ['label1', 'label2']
+    epic.save()
+
+Delete an epic::
+
+    epic.delete()
+
+Epics issues
+============
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.GroupEpicIssue`
+  + :class:`gitlab.v4.objects.GroupEpicIssueManager`
+  + :attr:`gitlab.Gitlab.GroupEpic.issues`
+
+* GitLab API: https://docs.gitlab.com/api/epic_issues (EE feature)
+
+Examples
+--------
+
+List the issues associated with an issue::
+
+    ei = epic.issues.list(get_all=True)
+
+Associate an issue with an epic::
+
+    # use the issue id, not its iid
+    ei = epic.issues.create({'issue_id': 4})
+
+Move an issue in the list::
+
+    ei.move_before_id = epic_issue_id_1
+    # or
+    ei.move_after_id = epic_issue_id_2
+    ei.save()
+
+Delete an issue association::
+
+    ei.delete()
diff --git a/docs/gl_objects/events.rst b/docs/gl_objects/events.rst
new file mode 100644
index 000000000..108f6cedb
--- /dev/null
+++ b/docs/gl_objects/events.rst
@@ -0,0 +1,83 @@
+######
+Events
+######
+
+Events
+======
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.Event`
+  + :class:`gitlab.v4.objects.EventManager`
+  + :attr:`gitlab.Gitlab.events`
+  + :class:`gitlab.v4.objects.ProjectEvent`
+  + :class:`gitlab.v4.objects.ProjectEventManager`
+  + :attr:`gitlab.v4.objects.Project.events`
+  + :class:`gitlab.v4.objects.UserEvent`
+  + :class:`gitlab.v4.objects.UserEventManager`
+  + :attr:`gitlab.v4.objects.User.events`
+
+* GitLab API: https://docs.gitlab.com/api/events/
+
+Examples
+--------
+
+You can list events for an entire Gitlab instance (admin), users and projects.
+You can filter you events you want to retrieve using the ``action`` and
+``target_type`` attributes. The possible values for these attributes are
+available on `the gitlab documentation
+<https://docs.gitlab.com/api/events/>`_.
+
+List all the events (paginated)::
+
+    events = gl.events.list(get_all=True)
+
+List the issue events on a project::
+
+    events = project.events.list(target_type='issue', get_all=True)
+
+List the user events::
+
+    events = project.events.list(get_all=True)
+
+Resource state events
+=====================
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectIssueResourceStateEvent`
+  + :class:`gitlab.v4.objects.ProjectIssueResourceStateEventManager`
+  + :attr:`gitlab.v4.objects.ProjectIssue.resourcestateevents`
+  + :class:`gitlab.v4.objects.ProjectMergeRequestResourceStateEvent`
+  + :class:`gitlab.v4.objects.ProjectMergeRequestResourceStateEventManager`
+  + :attr:`gitlab.v4.objects.ProjectMergeRequest.resourcestateevents`
+
+* GitLab API: https://docs.gitlab.com/api/resource_state_events
+
+Examples
+--------
+
+You can list and get specific resource state events (via their id) for project issues
+and project merge requests.
+
+List the state events of a project issue (paginated)::
+
+    state_events = issue.resourcestateevents.list(get_all=True)
+
+Get a specific state event of a project issue by its id::
+
+    state_event = issue.resourcestateevents.get(1)
+
+List the state events of a project merge request (paginated)::
+
+    state_events = mr.resourcestateevents.list(get_all=True)
+
+Get a specific state event of a project merge request by its id::
+
+    state_event = mr.resourcestateevents.get(1)
diff --git a/docs/gl_objects/features.rst b/docs/gl_objects/features.rst
new file mode 100644
index 000000000..d7552041d
--- /dev/null
+++ b/docs/gl_objects/features.rst
@@ -0,0 +1,32 @@
+##############
+Features flags
+##############
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.Feature`
+  + :class:`gitlab.v4.objects.FeatureManager`
+  + :attr:`gitlab.Gitlab.features`
+
+* GitLab API: https://docs.gitlab.com/api/features
+
+Examples
+--------
+
+List features::
+
+    features = gl.features.list(get_all=True)
+
+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::
+
+    feature.delete()
diff --git a/docs/gl_objects/geo_nodes.rst b/docs/gl_objects/geo_nodes.rst
new file mode 100644
index 000000000..4eb1932ed
--- /dev/null
+++ b/docs/gl_objects/geo_nodes.rst
@@ -0,0 +1,43 @@
+#########
+Geo nodes
+#########
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.GeoNode`
+  + :class:`gitlab.v4.objects.GeoNodeManager`
+  + :attr:`gitlab.Gitlab.geonodes`
+
+* GitLab API: https://docs.gitlab.com/api/geo_nodes (EE feature)
+
+Examples
+--------
+
+List the geo nodes::
+
+    nodes = gl.geonodes.list(get_all=True)
+
+Get the status of all the nodes::
+
+    status = gl.geonodes.status()
+
+Get a specific node and its status::
+
+    node = gl.geonodes.get(node_id)
+    node.status()
+
+Edit a node configuration::
+
+    node.url = 'https://secondary.mygitlab.domain'
+    node.save()
+
+Delete a node::
+
+    node.delete()
+
+List the sync failure on the current node::
+
+    failures = gl.geonodes.current_failures()
diff --git a/docs/gl_objects/group_access_tokens.rst b/docs/gl_objects/group_access_tokens.rst
new file mode 100644
index 000000000..26c694e5b
--- /dev/null
+++ b/docs/gl_objects/group_access_tokens.rst
@@ -0,0 +1,54 @@
+#####################
+Group Access Tokens
+#####################
+
+Get a list of group access tokens
+
+References
+----------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.GroupAccessToken`
+  + :class:`gitlab.v4.objects.GroupAccessTokenManager`
+  + :attr:`gitlab.Gitlab.group_access_tokens`
+
+* GitLab API: https://docs.gitlab.com/api/group_access_tokens
+
+Examples
+--------
+
+List group access tokens::
+
+    access_tokens = gl.groups.get(1, lazy=True).access_tokens.list(get_all=True)
+    print(access_tokens[0].name)
+
+Get a group access token by id::
+
+    token = group.access_tokens.get(123)
+    print(token.name)
+
+Create group access token::
+
+    access_token = gl.groups.get(1).access_tokens.create({"name": "test", "scopes": ["api"], "expires_at": "2023-06-06"})
+
+Revoke a group access token::
+
+    gl.groups.get(1).access_tokens.delete(42)
+    # or
+    access_token.delete()
+
+Rotate a group access token and retrieve its new value::
+
+    token = group.access_tokens.get(42, lazy=True)
+    token.rotate()
+    print(token.token)
+    # or directly using a token ID
+    new_token = group.access_tokens.rotate(42)
+    print(new_token.token)
+
+Self-Rotate the group access token you are using to authenticate the request and retrieve its new value::
+
+    token = group.access_tokens.get(42, lazy=True)
+    token.rotate(self_rotate=True)
+    print(token.token)
\ No newline at end of file
diff --git a/docs/gl_objects/groups.py b/docs/gl_objects/groups.py
deleted file mode 100644
index 8b4e88888..000000000
--- a/docs/gl_objects/groups.py
+++ /dev/null
@@ -1,66 +0,0 @@
-# list
-groups = gl.groups.list()
-# end list
-
-# search
-groups = gl.groups.search('group')
-# end search
-
-# get
-group = gl.groups.get(group_id)
-# end get
-
-# projects list
-projects = group.projects.list()
-# or
-projects = gl.group_projects.list(group_id)
-# end projects list
-
-# create
-group = gl.groups.create({'name': 'group1', 'path': 'group1'})
-# end create
-
-# update
-group.description = 'My awesome group'
-group.save()
-# end update
-
-# delete
-gl.group.delete(group_id)
-# or
-group.delete()
-# end delete
-
-# member list
-members = gl.group_members.list(group_id=1)
-# or
-members = group.members.list()
-# end member list
-
-# member get
-members = gl.group_members.get(member_id)
-# or
-members = group.members.get(member_id)
-# end member get
-
-# member create
-member = gl.group_members.create({'user_id': user_id,
-                                  'access_level': gitlab.GUEST_ACCESS},
-                                 group_id=1)
-# or
-member = group.members.create({'user_id': user_id,
-                               'access_level': gitlab.GUEST_ACCESS})
-# end member create
-
-# member update
-member.access_level = gitlab.DEVELOPER_ACCESS
-member.save()
-# end member update
-
-# member delete
-gl.group_members.delete(member_id, group_id=1)
-# or
-group.members.delete(member_id)
-# or
-member.delete()
-# end member delete
diff --git a/docs/gl_objects/groups.rst b/docs/gl_objects/groups.rst
index b2c0ed865..7824ef31b 100644
--- a/docs/gl_objects/groups.rst
+++ b/docs/gl_objects/groups.rst
@@ -5,35 +5,44 @@ Groups
 Groups
 ======
 
-Use :class:`~gitlab.objects.Group` objects to manipulate groups. The
-:attr:`gitlab.Gitlab.groups` manager object provides helper functions.
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.Group`
+  + :class:`gitlab.v4.objects.GroupManager`
+  + :attr:`gitlab.Gitlab.groups`
+
+* GitLab API: https://docs.gitlab.com/api/groups
 
 Examples
 --------
 
-List the groups:
+List the groups::
+
+    groups = gl.groups.list(get_all=True)
+
+Get a group's detail::
+
+    group = gl.groups.get(group_id)
 
-.. literalinclude:: groups.py
-   :start-after: # list
-   :end-before: # end list
+List a group's projects::
 
-Search groups:
+    projects = group.projects.list(get_all=True)
 
-.. literalinclude:: groups.py
-   :start-after: # search
-   :end-before: # end search
+List a group's shared projects::
 
-Get a group's detail:
+    projects = group.shared_projects.list(get_all=True)
 
-.. literalinclude:: groups.py
-   :start-after: # get
-   :end-before: # end get
+.. note::
 
-List a group's projects:
+   ``GroupProject`` and ``SharedProject`` objects returned by these two API calls
+   are very limited, and do not provide all the features of ``Project`` objects.
+   If you need to manipulate projects, create a new ``Project`` object::
 
-.. literalinclude:: groups.py
-   :start-after: # projects list
-   :end-before: # end projects list
+       first_group_project = group.projects.list(get_all=False)[0]
+       manageable_project = gl.projects.get(first_group_project.id, lazy=True)
 
 You can filter and sort the result using the following parameters:
 
@@ -45,67 +54,437 @@ You can filter and sort the result using the following parameters:
   ``created_at``, ``updated_at`` and ``last_activity_at``
 * ``sort``: sort order: ``asc`` or ``desc``
 * ``ci_enabled_first``: return CI enabled groups first
+* ``include_subgroups``: include projects in subgroups
+
+Create a group::
+
+    group = gl.groups.create({'name': 'group1', 'path': 'group1'})
+
+.. warning::
+
+   On GitLab.com, creating top-level groups is currently
+   `not permitted using the API <https://docs.gitlab.com/api/groups#new-group>`_.
+   You can only use the API to create subgroups.
+
+Create a subgroup under an existing group::
+
+    subgroup = gl.groups.create({'name': 'subgroup1', 'path': 'subgroup1', 'parent_id': parent_group_id})
+
+Update a group::
+
+    group.description = 'My awesome group'
+    group.save()
+
+Set the avatar image for a group::
+
+    # the avatar image can be passed as data (content of the file) or as a file
+    # object opened in binary mode
+    group.avatar = open('path/to/file.png', 'rb')
+    group.save()
+
+Remove the avatar image for a group::
+
+    group.avatar = ""
+    group.save()
+
+Remove a group::
+
+    gl.groups.delete(group_id)
+    # or
+    group.delete()
+
+Restore a Group marked for deletion (Premium only):::
+
+    group.restore()
+
+
+Share/unshare the group with a group::
+
+    group.share(group2.id, gitlab.const.AccessLevel.DEVELOPER)
+    group.unshare(group2.id)
+
+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/api/group_import_export
+
+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()
 
-Create a group:
+    # Wait for the export to finish
+    time.sleep(3)
 
-.. literalinclude:: groups.py
-   :start-after: # create
-   :end-before: # end create
+    # Download the result
+    with open('/tmp/export.tgz', 'wb') as f:
+        export.download(streamed=True, action=f.write)
 
-Update a group:
+Import the group::
 
-.. literalinclude:: groups.py
-   :start-after: # update
-   :end-before: # end update
+    with open('/tmp/export.tgz', 'rb') as f:
+        gl.groups.import_group(f, path='imported-group', name="Imported Group")
 
-Remove a group:
+Subgroups
+=========
 
-.. literalinclude:: groups.py
-   :start-after: # delete
-   :end-before: # end delete
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.GroupSubgroup`
+  + :class:`gitlab.v4.objects.GroupSubgroupManager`
+  + :attr:`gitlab.v4.objects.Group.subgroups`
+
+Examples
+--------
+
+List the subgroups for a group::
+
+    subgroups = group.subgroups.list(get_all=True)
+
+.. note::
+
+    The ``GroupSubgroup`` objects don't expose the same API as the ``Group``
+    objects.  If you need to manipulate a subgroup as a group, create a new
+    ``Group`` object::
+
+        real_group = gl.groups.get(subgroup_id, lazy=True)
+        real_group.issues.list(get_all=True)
+
+Descendant Groups
+=================
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.GroupDescendantGroup`
+  + :class:`gitlab.v4.objects.GroupDescendantGroupManager`
+  + :attr:`gitlab.v4.objects.Group.descendant_groups`
+
+Examples
+--------
+
+List the descendant groups of a group::
+
+    descendant_groups = group.descendant_groups.list(get_all=True)
+
+.. note::
+
+    Like the ``GroupSubgroup`` objects described above, ``GroupDescendantGroup``
+    objects do not expose the same API as the ``Group`` objects. Create a new
+    ``Group`` object instead if needed, as shown in the subgroup example.
+
+Group custom attributes
+=======================
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.GroupCustomAttribute`
+  + :class:`gitlab.v4.objects.GroupCustomAttributeManager`
+  + :attr:`gitlab.v4.objects.Group.customattributes`
+
+* GitLab API: https://docs.gitlab.com/api/custom_attributes
+
+Examples
+--------
+
+List custom attributes for a group::
+
+    attrs = group.customattributes.list(get_all=True)
+
+Get a custom attribute for a group::
+
+    attr = group.customattributes.get(attr_key)
+
+Set (create or update) a custom attribute for a group::
+
+    attr = group.customattributes.set(attr_key, attr_value)
+
+Delete a custom attribute for a group::
+
+    attr.delete()
+    # or
+    group.customattributes.delete(attr_key)
+
+Search groups by custom attribute::
+
+    group.customattributes.set('role': 'admin')
+    gl.groups.list(custom_attributes={'role': 'admin'}, get_all=True)
 
 Group members
 =============
 
-Use :class:`~gitlab.objects.GroupMember` objects to manipulate groups. The
-:attr:`gitlab.Gitlab.group_members` and :attr:`Group.members
-<gitlab.objects.Group.members>` manager objects provide helper functions.
+The following constants define the supported access levels:
 
-The following :class:`~gitlab.objects.Group` attributes define the supported
-access levels:
+* ``gitlab.const.AccessLevel.GUEST = 10``
+* ``gitlab.const.AccessLevel.REPORTER = 20``
+* ``gitlab.const.AccessLevel.DEVELOPER = 30``
+* ``gitlab.const.AccessLevel.MAINTAINER = 40``
+* ``gitlab.const.AccessLevel.OWNER = 50``
 
-* ``GUEST_ACCESS = 10``
-* ``REPORTER_ACCESS = 20``
-* ``DEVELOPER_ACCESS = 30``
-* ``MASTER_ACCESS = 40``
-* ``OWNER_ACCESS = 50``
+Reference
+---------
 
-List group members:
+* v4 API:
 
-.. literalinclude:: groups.py
-   :start-after: # member list
-   :end-before: # end member list
+  + :class:`gitlab.v4.objects.GroupMember`
+  + :class:`gitlab.v4.objects.GroupMemberManager`
+  + :class:`gitlab.v4.objects.GroupMemberAllManager`
+  + :class:`gitlab.v4.objects.GroupBillableMember`
+  + :class:`gitlab.v4.objects.GroupBillableMemberManager`
+  + :attr:`gitlab.v4.objects.Group.members`
+  + :attr:`gitlab.v4.objects.Group.members_all`
+  + :attr:`gitlab.v4.objects.Group.billable_members`
 
-Get a group member:
+* GitLab API: https://docs.gitlab.com/api/members
 
-.. literalinclude:: groups.py
-   :start-after: # member get
-   :end-before: # end member get
+Billable group members are only available in GitLab EE.
 
-Add a member to the group:
+Examples
+--------
 
-.. literalinclude:: groups.py
-   :start-after: # member create
-   :end-before: # end member create
+List only direct group members::
 
-Update a member (change the access level):
+    members = group.members.list(get_all=True)
 
-.. literalinclude:: groups.py
-   :start-after: # member update
-   :end-before: # end member update
+List the group members recursively (including inherited members through
+ancestor groups)::
+
+    members = group.members_all.list(get_all=True)
+
+Get only direct group member::
+
+    members = group.members.get(member_id)
+
+Get a member of a group, including members inherited through ancestor groups::
+
+    members = group.members_all.get(member_id)
+
+Add a member to the group::
+
+    member = group.members.create({'user_id': user_id,
+                                   'access_level': gitlab.const.AccessLevel.GUEST})
+
+Update a member (change the access level)::
+
+    member.access_level = gitlab.const.AccessLevel.DEVELOPER
+    member.save()
+
+Remove a member from the group::
+
+    group.members.delete(member_id)
+    # or
+    member.delete()
+
+List billable members of a group (top-level groups only)::
+
+    billable_members = group.billable_members.list(get_all=True)
+
+Remove a billable member from the group::
+
+    group.billable_members.delete(member_id)
+    # or
+    billable_member.delete()
+
+List memberships of a billable member::
+
+    billable_member.memberships.list(get_all=True)
+
+LDAP group links
+================
+
+Add an LDAP group link to an existing GitLab group::
+
+    ldap_link = group.ldap_group_links.create({
+        'provider': 'ldapmain',
+        'group_access': gitlab.const.AccessLevel.DEVELOPER,
+        'cn: 'ldap_group_cn'
+    })
+
+List a group's LDAP group links::
+
+    group.ldap_group_links.list(get_all=True)
+
+Remove a link::
+
+    ldap_link.delete()
+    # or by explicitly providing the CN or filter
+    group.ldap_group_links.delete(provider='ldapmain', cn='ldap_group_cn')
+    group.ldap_group_links.delete(provider='ldapmain', filter='(cn=Common Name)')
+
+Sync the LDAP groups::
+
+    group.ldap_sync()
+
+You can use the ``ldapgroups`` manager to list available LDAP groups::
+
+    # listing (supports pagination)
+    ldap_groups = gl.ldapgroups.list(get_all=True)
+
+    # filter using a group name
+    ldap_groups = gl.ldapgroups.list(search='foo', get_all=True)
+
+    # list the groups for a specific LDAP provider
+    ldap_groups = gl.ldapgroups.list(search='foo', provider='ldapmain', get_all=True)
+
+SAML group links
+================
+
+Add a SAML group link to an existing GitLab group::
+
+    saml_link = group.saml_group_links.create({
+        "saml_group_name": "<your_saml_group_name>",
+        "access_level": <chosen_access_level>
+    })
+
+List a group's SAML group links::
+
+    group.saml_group_links.list(get_all=True)
+
+Get a SAML group link::
+
+    group.saml_group_links.get("<your_saml_group_name>")
+
+Remove a link::
+
+    saml_link.delete()
+
+Groups hooks
+============
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.GroupHook`
+  + :class:`gitlab.v4.objects.GroupHookManager`
+  + :attr:`gitlab.v4.objects.Group.hooks`
+
+* GitLab API: https://docs.gitlab.com/api/groups#hooks
+
+Examples
+--------
+
+List the group hooks::
+
+    hooks = group.hooks.list(get_all=True)
+
+Get a group hook::
+
+    hook = group.hooks.get(hook_id)
+
+Create a group hook::
+
+    hook = group.hooks.create({'url': 'http://my/action/url', 'push_events': 1})
+
+Update a group hook::
+
+    hook.push_events = 0
+    hook.save()
+
+Test a group hook::
+
+    hook.test("push_events")
+
+Delete a group hook::
+
+    group.hooks.delete(hook_id)
+    # or
+    hook.delete()
+
+Group push rules
+==================
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.GroupPushRules`
+  + :class:`gitlab.v4.objects.GroupPushRulesManager`
+  + :attr:`gitlab.v4.objects.Group.pushrules`
+
+* GitLab API: https://docs.gitlab.com/api/groups#push-rules
+
+Examples
+---------
+
+Create group push rules (at least one rule is necessary)::
+
+    group.pushrules.create({'deny_delete_tag': True})
+
+Get group push rules::
+
+    pr = group.pushrules.get()
+
+Edit group push rules::
+
+    pr.branch_name_regex = '^(master|develop|support-\d+|release-\d+\..+|hotfix-.+|feature-.+)$'
+    pr.save()
+
+Delete group push rules::
+
+    pr.delete()
+
+Group Service Account
+=====================
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.GroupServiceAccount`
+  + :class:`gitlab.v4.objects.GroupServiceAccountManager`
+  + :attr:`gitlab.v4.objects.Group.serviceaccounts`
+
+* GitLab API: https://docs.gitlab.com/api/groups#service-accounts
+
+Examples
+---------
 
-Remove a member from the group:
+Create group service account (only allowed at top level group)::
 
-.. literalinclude:: groups.py
-   :start-after: # member delete
-   :end-before: # end member delete
+    group.serviceaccount.create({'name': 'group-service-account', 'username': 'group-service-account'})
diff --git a/docs/gl_objects/invitations.rst b/docs/gl_objects/invitations.rst
new file mode 100644
index 000000000..e88564f6d
--- /dev/null
+++ b/docs/gl_objects/invitations.rst
@@ -0,0 +1,73 @@
+###########
+Invitations
+###########
+
+Invitations let you invite or add users to a group or project.
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.GroupInvitation`
+  + :class:`gitlab.v4.objects.GroupInvitationManager`
+  + :attr:`gitlab.v4.objects.Group.invitations`
+  + :class:`gitlab.v4.objects.ProjectInvitation`
+  + :class:`gitlab.v4.objects.ProjectInvitationManager`
+  + :attr:`gitlab.v4.objects.Project.invitations`
+
+* GitLab API: https://docs.gitlab.com/api/invitations
+
+Examples
+--------
+
+.. danger::
+
+    Creating an invitation with ``create()`` returns a status response,
+    rather than invitation details, because it allows sending multiple
+    invitations at the same time.
+    
+    Thus when using several emails, you do not create a real invitation
+    object you can manipulate, because python-gitlab cannot know which email
+    to track as the ID.
+    
+    In that case, use a **lazy** ``get()`` method shown below using a specific
+    email address to create an invitation object you can manipulate.
+
+Create an invitation::
+
+    invitation = group_or_project.invitations.create(
+        {
+            "email": "email@example.com",
+            "access_level": gitlab.const.AccessLevel.DEVELOPER,
+        }
+    )
+
+List invitations for a group or project::
+
+    invitations = group_or_project.invitations.list(get_all=True)
+
+.. warning::
+
+    As mentioned above, GitLab does not provide a real GET endpoint for a single
+    invitation. We can create a lazy object to later manipulate it.
+
+Update an invitation::
+
+    invitation = group_or_project.invitations.get("email@example.com", lazy=True)
+    invitation.access_level = gitlab.const.AccessLevel.DEVELOPER
+    invitation.save()
+
+    # or
+    group_or_project.invitations.update(
+        "email@example.com",
+        {"access_level": gitlab.const.AccessLevel.DEVELOPER}
+    )
+
+Delete an invitation::
+
+    invitation = group_or_project.invitations.get("email@example.com", lazy=True)
+    invitation.delete()
+
+    # or
+    group_or_project.invitations.delete("email@example.com")
diff --git a/docs/gl_objects/issues.py b/docs/gl_objects/issues.py
deleted file mode 100644
index ad48dc80e..000000000
--- a/docs/gl_objects/issues.py
+++ /dev/null
@@ -1,79 +0,0 @@
-# list
-issues = gl.issues.list()
-# end list
-
-# filtered list
-open_issues = gl.issues.list(state='opened')
-closed_issues = gl.issues.list(state='closed')
-tagged_issues = gl.issues.list(labels=['foo', 'bar'])
-# end filtered list
-
-# group issues list
-issues = gl.group_issues.list(group_id=1)
-# or
-issues = group.issues.list()
-# Filter using the state, labels and milestone parameters
-issues = group.issues.list(milestone='1.0', state='opened')
-# Order using the order_by and sort parameters
-issues = group.issues.list(order_by='created_at', sort='desc')
-# end group issues list
-
-# project issues list
-issues = gl.project_issues.list(project_id=1)
-# or
-issues = project.issues.list()
-# Filter using the state, labels and milestone parameters
-issues = project.issues.list(milestone='1.0', state='opened')
-# Order using the order_by and sort parameters
-issues = project.issues.list(order_by='created_at', sort='desc')
-# end project issues list
-
-# project issues get
-issue = gl.project_issues.get(issue_id, project_id=1)
-# or
-issue = project.issues.get(issue_id)
-# end project issues get
-
-# project issues create
-issue = gl.project_issues.create({'title': 'I have a bug',
-                                  'description': 'Something useful here.'},
-                                 project_id=1)
-# or
-issue = project.issues.create({'title': 'I have a bug',
-                               'description': 'Something useful here.'})
-# end project issues create
-
-# project issue update
-issue.labels = ['foo', 'bar']
-issue.save()
-# end project issue update
-
-# project issue open_close
-# close an issue
-issue.state_event = 'close'
-issue.save()
-# reopen it
-issue.state_event = 'reopen'
-issue.save()
-# end project issue open_close
-
-# project issue delete
-gl.project_issues.delete(issue_id, project_id=1)
-# or
-project.issues.delete(issue_id)
-# pr
-issue.delete()
-# end project issue delete
-
-# project issue subscribe
-issue.subscribe()
-issue.unsubscribe()
-# end project issue subscribe
-
-# project issue move
-issue.move(new_project_id)
-# end project issue move
-
-# project issue todo
-issue.todo()
-# end project issue todo
diff --git a/docs/gl_objects/issues.rst b/docs/gl_objects/issues.rst
index d4cbf003d..ea17af728 100644
--- a/docs/gl_objects/issues.rst
+++ b/docs/gl_objects/issues.rst
@@ -1,3 +1,5 @@
+.. _issues_examples:
+
 ######
 Issues
 ######
@@ -5,102 +7,296 @@ Issues
 Reported issues
 ===============
 
-Use :class:`~gitlab.objects.Issues` objects to manipulate issues the
-authenticated user reported. The :attr:`gitlab.Gitlab.issues` manager object
-provides helper functions.
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.Issue`
+  + :class:`gitlab.v4.objects.IssueManager`
+  + :attr:`gitlab.Gitlab.issues`
+
+* GitLab API: https://docs.gitlab.com/api/issues
 
 Examples
 --------
 
-List the issues:
+List the issues::
 
-.. literalinclude:: issues.py
-   :start-after: # list
-   :end-before: # end list
+    issues = gl.issues.list(get_all=True)
 
 Use the ``state`` and ``label`` parameters to filter the results. Use the
-``order_by`` and ``sort`` attributes to sort the results:
+``order_by`` and ``sort`` attributes to sort the results::
+
+    open_issues = gl.issues.list(state='opened', get_all=True)
+    closed_issues = gl.issues.list(state='closed', get_all=True)
+    tagged_issues = gl.issues.list(labels=['foo', 'bar'], get_all=True)
+
+.. note::
 
-.. literalinclude:: issues.py
-   :start-after: # filtered list
-   :end-before: # end filtered list
+   It is not possible to edit or delete Issue objects. You need to create a
+   ProjectIssue object to perform changes::
+
+       issue = gl.issues.list(get_all=False)[0]
+       project = gl.projects.get(issue.project_id, lazy=True)
+       editable_issue = project.issues.get(issue.iid, lazy=True)
+       editable_issue.title = updated_title
+       editable_issue.save()
 
 Group issues
 ============
 
-Use :class:`~gitlab.objects.GroupIssue` objects to manipulate issues. The
-:attr:`gitlab.Gitlab.project_issues` and :attr:`Group.issues
-<gitlab.objects.Group.issues>` manager objects provide helper functions.
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.GroupIssue`
+  + :class:`gitlab.v4.objects.GroupIssueManager`
+  + :attr:`gitlab.v4.objects.Group.issues`
+
+* GitLab API: https://docs.gitlab.com/api/issues
 
 Examples
 --------
 
-List the group issues:
+List the group issues::
+
+    issues = group.issues.list(get_all=True)
+    # Filter using the state, labels and milestone parameters
+    issues = group.issues.list(milestone='1.0', state='opened', get_all=True)
+    # Order using the order_by and sort parameters
+    issues = group.issues.list(order_by='created_at', sort='desc', get_all=True)
 
-.. literalinclude:: issues.py
-   :start-after: # group issues list
-   :end-before: # end group issues list
+.. note::
+
+   It is not possible to edit or delete GroupIssue objects. You need to create
+   a ProjectIssue object to perform changes::
+
+       issue = group.issues.list(get_all=False)[0]
+       project = gl.projects.get(issue.project_id, lazy=True)
+       editable_issue = project.issues.get(issue.iid, lazy=True)
+       editable_issue.title = updated_title
+       editable_issue.save()
 
 Project issues
 ==============
 
-Use :class:`~gitlab.objects.ProjectIssue` objects to manipulate issues. The
-:attr:`gitlab.Gitlab.project_issues` and :attr:`Project.issues
-<gitlab.objects.Project.issues>` manager objects provide helper functions.
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectIssue`
+  + :class:`gitlab.v4.objects.ProjectIssueManager`
+  + :attr:`gitlab.v4.objects.Project.issues`
+
+* GitLab API: https://docs.gitlab.com/api/issues
+
+Examples
+--------
+
+List the project issues::
+
+    issues = project.issues.list(get_all=True)
+    # Filter using the state, labels and milestone parameters
+    issues = project.issues.list(milestone='1.0', state='opened', get_all=True)
+    # Order using the order_by and sort parameters
+    issues = project.issues.list(order_by='created_at', sort='desc', get_all=True)
+
+Get a project issue::
+
+    issue = project.issues.get(issue_iid)
+
+Create a new issue::
+
+    issue = project.issues.create({'title': 'I have a bug',
+                                   'description': 'Something useful here.'})
+
+Update an issue::
+
+    issue.labels = ['foo', 'bar']
+    issue.save()
+
+Close / reopen an issue::
+
+    # close an issue
+    issue.state_event = 'close'
+    issue.save()
+    # reopen it
+    issue.state_event = 'reopen'
+    issue.save()
+
+Delete an issue (admin or project owner only)::
+
+    project.issues.delete(issue_id)
+    # or
+    issue.delete()
+
+
+Assign the issues::
+
+    issue = gl.issues.list(get_all=False)[0]
+    issue.assignee_ids = [25, 10, 31, 12]
+    issue.save()
+
+.. note::
+    The Gitlab API explicitly references that the `assignee_id` field is deprecated,
+    so using a list of user IDs for `assignee_ids` is how to assign an issue to a user(s).
+
+Subscribe / unsubscribe from an issue::
+
+    issue.subscribe()
+    issue.unsubscribe()
+
+Move an issue to another project::
+
+    issue.move(other_project_id)
+
+Reorder an issue on a board::
+
+    issue.reorder(move_after_id=2, move_before_id=3)
+
+Make an issue as todo::
+
+    issue.todo()
+
+Get time tracking stats::
+
+    issue.time_stats()
+
+On recent versions of Gitlab the time stats are also returned as an issue
+object attribute::
+
+    issue = project.issue.get(iid)
+    print(issue.attributes['time_stats'])
+
+Set a time estimate for an issue::
+
+    issue.time_estimate('3h30m')
+
+Reset a time estimate for an issue::
+
+    issue.reset_time_estimate()
+
+Add spent time for an issue::
+
+    issue.add_spent_time('3h30m')
+
+Reset spent time for an issue::
+
+    issue.reset_spent_time()
+
+Get user agent detail for the issue (admin only)::
+
+    detail = issue.user_agent_detail()
+
+Get the list of merge requests that will close an issue when merged::
+
+    mrs = issue.closed_by()
+
+Get the merge requests related to an issue::
+
+    mrs = issue.related_merge_requests()
+
+Get the list of participants::
+
+    users = issue.participants()
+
+Get the list of iteration events::
+
+    iteration_events = issue.resource_iteration_events.list(get_all=True)
+
+Get the list of weight events::
+
+    weight_events = issue.resource_weight_events.list(get_all=True)
+
+Issue links
+===========
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectIssueLink`
+  + :class:`gitlab.v4.objects.ProjectIssueLinkManager`
+  + :attr:`gitlab.v4.objects.ProjectIssue.links`
+
+* GitLab API: https://docs.gitlab.com/api/issue_links
 
 Examples
 --------
 
-List the project issues:
+List the issues linked to ``i1``::
+
+    links = i1.links.list(get_all=True)
+
+Link issue ``i1`` to issue ``i2``::
 
-.. literalinclude:: issues.py
-   :start-after: # project issues list
-   :end-before: # end project issues list
+    data = {
+        'target_project_id': i2.project_id,
+        'target_issue_iid': i2.iid
+    }
+    src_issue, dest_issue = i1.links.create(data)
 
-Get a project issue:
+.. note::
 
-.. literalinclude:: issues.py
-   :start-after: # project issues get
-   :end-before: # end project issues get
+   The ``create()`` method returns the source and destination ``ProjectIssue``
+   objects, not a ``ProjectIssueLink`` object.
+
+Delete a link::
+
+    i1.links.delete(issue_link_id)
+
+Issues statistics
+=========================
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.IssuesStatistics`
+  + :class:`gitlab.v4.objects.IssuesStatisticsManager`
+  + :attr:`gitlab.issues_statistics`
+  + :class:`gitlab.v4.objects.GroupIssuesStatistics`
+  + :class:`gitlab.v4.objects.GroupIssuesStatisticsManager`
+  + :attr:`gitlab.v4.objects.Group.issues_statistics`
+  + :class:`gitlab.v4.objects.ProjectIssuesStatistics`
+  + :class:`gitlab.v4.objects.ProjectIssuesStatisticsManager`
+  + :attr:`gitlab.v4.objects.Project.issues_statistics`
+
+
+* GitLab API: https://docs.gitlab.com/api/issues_statistics/
+
+Examples
+---------
 
-Create a new issue:
+Get statistics of all issues created by the current user::
 
-.. literalinclude:: issues.py
-   :start-after: # project issues create
-   :end-before: # end project issues create
+    statistics = gl.issues_statistics.get()
 
-Update an issue:
+Get statistics of all issues the user has access to::
 
-.. literalinclude:: issues.py
-   :start-after: # project issue update
-   :end-before: # end project issue update
+    statistics = gl.issues_statistics.get(scope='all')
 
-Close / reopen an issue:
+Get statistics of issues for the user with ``foobar`` in the ``title`` or the ``description``::
 
-.. literalinclude:: issues.py
-   :start-after: # project issue open_close
-   :end-before: # end project issue open_close
+    statistics = gl.issues_statistics.get(search='foobar')
 
-Delete an issue:
+Get statistics of all issues in a group::
 
-.. literalinclude:: issues.py
-   :start-after: # project issue delete
-   :end-before: # end project issue delete
+    statistics = group.issues_statistics.get()
 
-Subscribe / unsubscribe from an issue:
+Get statistics of issues in a group with ``foobar`` in the ``title`` or the ``description``::
 
-.. literalinclude:: issues.py
-   :start-after: # project issue subscribe
-   :end-before: # end project issue subscribe
+    statistics = group.issues_statistics.get(search='foobar')
 
-Move an issue to another project:
+Get statistics of all issues in a project::
 
-.. literalinclude:: issues.py
-   :start-after: # project issue move
-   :end-before: # end project issue move
+    statistics = project.issues_statistics.get()
 
-Make an issue as todo:
+Get statistics of issues in a project with ``foobar`` in the ``title`` or the ``description``::
 
-.. literalinclude:: issues.py
-   :start-after: # project issue todo
-   :end-before: # end project issue todo
+    statistics = project.issues_statistics.get(search='foobar')
diff --git a/docs/gl_objects/iterations.rst b/docs/gl_objects/iterations.rst
new file mode 100644
index 000000000..3f5e763bf
--- /dev/null
+++ b/docs/gl_objects/iterations.rst
@@ -0,0 +1,43 @@
+##########
+Iterations
+##########
+
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.GroupIteration`
+  + :class:`gitlab.v4.objects.GroupIterationManager`
+  + :attr:`gitlab.v4.objects.Group.iterations`
+  + :class:`gitlab.v4.objects.ProjectIterationManager`
+  + :attr:`gitlab.v4.objects.Project.iterations`
+
+* GitLab API: https://docs.gitlab.com/api/iterations
+
+Examples
+--------
+
+.. note::
+
+    GitLab no longer has project iterations. Using a project endpoint returns
+    the ancestor groups' iterations. 
+
+List iterations for a project's ancestor groups::
+
+    iterations = project.iterations.list(get_all=True)
+
+List iterations for a group::
+
+    iterations = group.iterations.list(get_all=True)
+
+Unavailable filters or keyword conflicts::
+    
+    In case you are trying to pass a parameter that collides with a python
+    keyword (i.e. `in`) or with python-gitlab's internal arguments, you'll have
+    to use the `query_parameters` argument:
+
+    ```
+    group.iterations.list(query_parameters={"in": "title"}, get_all=True)
+    ```
diff --git a/docs/gl_objects/job_token_scope.rst b/docs/gl_objects/job_token_scope.rst
new file mode 100644
index 000000000..8857e2251
--- /dev/null
+++ b/docs/gl_objects/job_token_scope.rst
@@ -0,0 +1,99 @@
+#####################
+CI/CD job token scope
+#####################
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectJobTokenScope`
+  + :class:`gitlab.v4.objects.ProjectJobTokenScopeManager`
+  + :attr:`gitlab.v4.objects.Project.job_token_scope`
+
+* GitLab API: https://docs.gitlab.com/api/project_job_token_scopes
+
+Examples
+--------
+
+.. warning::
+
+   The GitLab API does **not** return any data when saving or updating
+   the job token scope settings. You need to call ``refresh()`` (or ``get()``
+   a new object) as shown below to get the latest state.
+
+Get a project's CI/CD job token access settings::
+
+    scope = project.job_token_scope.get()
+    print(scope.inbound_enabled)
+    # True
+
+Update the job token scope settings::
+
+    scope.enabled = False
+    scope.save()
+
+.. warning::
+
+   As you can see above, the attributes you receive from and send to the GitLab API
+   are not consistent. GitLab returns ``inbound_enabled`` and ``outbound_enabled``,
+   but expects ``enabled``, which only refers to the inbound scope. This is important
+   when accessing and updating these attributes.
+
+Or update the job token scope settings directly::
+
+    project.job_token_scope.update(new_data={"enabled": True})
+
+Refresh the current state of job token scope::
+
+    scope.refresh()
+    print(scope.inbound_enabled)
+    # False
+
+Get a project's CI/CD job token inbound allowlist::
+
+    allowlist = scope.allowlist.list(get_all=True)
+
+Add a project to the project's inbound allowlist::
+
+    allowed_project = scope.allowlist.create({"target_project_id": 42})
+
+Remove a project from the project's inbound allowlist::
+
+    allowed_project.delete()
+    # or directly using a project ID
+    scope.allowlist.delete(42)
+
+.. warning::
+
+   Similar to above, the ID attributes you receive from the create and list
+   APIs are not consistent (in create() the id is returned as ``source_project_id`` whereas list() returns as ``id``). To safely retrieve the ID of the allowlisted project
+   regardless of how the object was created, always use its ``.get_id()`` method.
+
+Using ``.get_id()``::
+
+    resp = allowlist.create({"target_project_id": 2})
+    allowlist_id = resp.get_id()
+
+    for allowlist in project.allowlist.list(iterator=True):
+      allowlist_id == allowlist.get_id()
+
+Get a project's CI/CD job token inbound groups allowlist::
+
+    allowlist = scope.groups_allowlist.list(get_all=True)
+
+Add a group to the project's inbound groups allowlist::
+
+    allowed_group = scope.groups_allowlist.create({"target_group_id": 42})
+
+Remove a group from the project's inbound groups allowlist::
+
+    allowed_group.delete()
+    # or directly using a Group ID
+    scope.groups_allowlist.delete(42)
+
+.. warning::
+
+   Similar to above, the ID attributes you receive from the create and list
+   APIs are not consistent. To safely retrieve the ID of the allowlisted group
+   regardless of how the object was created, always use its ``.get_id()`` method.
diff --git a/docs/gl_objects/keys.rst b/docs/gl_objects/keys.rst
new file mode 100644
index 000000000..4450ed708
--- /dev/null
+++ b/docs/gl_objects/keys.rst
@@ -0,0 +1,28 @@
+####
+Keys
+####
+
+Keys
+====
+
+Reference
+---------
+
+* v4 API
+
+  + :class:`gitlab.v4.objects.Key`
+  + :class:`gitlab.v4.objects.KeyManager`
+  + :attr:`gitlab.Gitlab.keys`
+
+* GitLab API: https://docs.gitlab.com/api/keys
+
+Examples
+--------
+
+Get an ssh key by its id (requires admin access)::
+
+    key = gl.keys.get(key_id)
+
+Get an ssh key (requires admin access) or a deploy key by its fingerprint::
+
+    key = gl.keys.get(fingerprint="SHA256:ERJJ/OweAM6jA8OjJ/gXs4N5fqUaREEJnz/EyfywfXY")
diff --git a/docs/gl_objects/labels.py b/docs/gl_objects/labels.py
deleted file mode 100644
index 9a363632c..000000000
--- a/docs/gl_objects/labels.py
+++ /dev/null
@@ -1,35 +0,0 @@
-# list
-labels = gl.project_labels.list(project_id=1)
-# or
-labels = project.labels.list()
-# end list
-
-# get
-label = gl.project_labels.get(label_name, project_id=1)
-# or
-label = project.labels.get(label_name)
-# end get
-
-# create
-label = gl.project_labels.create({'name': 'foo', 'color': '#8899aa'},
-                                 project_id=1)
-# or
-label = project.labels.create({'name': 'foo', 'color': '#8899aa'})
-# end create
-
-# update
-# change the name of the label:
-label.new_name = 'bar'
-label.save()
-# change its color:
-label.color = '#112233'
-label.save()
-# end update
-
-# delete
-gl.project_labels.delete(label_id, project_id=1)
-# or
-project.labels.delete(label_id)
-# or
-label.delete()
-# end delete
diff --git a/docs/gl_objects/labels.rst b/docs/gl_objects/labels.rst
index 3973b0b90..7fa042fab 100644
--- a/docs/gl_objects/labels.rst
+++ b/docs/gl_objects/labels.rst
@@ -2,39 +2,92 @@
 Labels
 ######
 
-Use :class:`~gitlab.objects.ProjectLabel` objects to manipulate labels for
-projects. The :attr:`gitlab.Gitlab.project_labels` and :attr:`Project.labels
-<gitlab.objects.Project.labels>` manager objects provide helper functions.
+Project labels
+==============
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectLabel`
+  + :class:`gitlab.v4.objects.ProjectLabelManager`
+  + :attr:`gitlab.v4.objects.Project.labels`
+
+* GitLab API: https://docs.gitlab.com/api/labels
 
 Examples
 --------
 
-List labels for a project:
+List labels for a project::
+
+    labels = project.labels.list(get_all=True)
+
+Create a label for a project::
+
+    label = project.labels.create({'name': 'foo', 'color': '#8899aa'})
+
+Update a label for a project::
+
+    # change the name of the label:
+    label.new_name = 'bar'
+    label.save()
+    # change its color:
+    label.color = '#112233'
+    label.save()
+
+Promote a project label to a group label::
 
-.. literalinclude:: labels.py
-   :start-after: # list
-   :end-before: # end list
+    label.promote()
 
-Get a single label:
+Delete a label for a project::
 
-.. literalinclude:: labels.py
-   :start-after: # get
-   :end-before: # end get
+    project.labels.delete(label_id)
+    # or
+    label.delete()
 
-Create a label for a project:
+Manage labels in issues and merge requests::
 
-.. literalinclude:: labels.py
-   :start-after: # create
-   :end-before: # end create
+    # Labels are defined as lists in issues and merge requests. The labels must
+    # exist.
+    issue = p.issues.create({'title': 'issue title',
+                             'description': 'issue description',
+                             'labels': ['foo']})
+    issue.labels.append('bar')
+    issue.save()
+
+Label events
+============
+
+Resource label events keep track about who, when, and which label was added or
+removed to an issuable.
+
+Group epic label events are only available in the EE edition.
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectIssueResourceLabelEvent`
+  + :class:`gitlab.v4.objects.ProjectIssueResourceLabelEventManager`
+  + :attr:`gitlab.v4.objects.ProjectIssue.resourcelabelevents`
+  + :class:`gitlab.v4.objects.ProjectMergeRequestResourceLabelEvent`
+  + :class:`gitlab.v4.objects.ProjectMergeRequestResourceLabelEventManager`
+  + :attr:`gitlab.v4.objects.ProjectMergeRequest.resourcelabelevents`
+  + :class:`gitlab.v4.objects.GroupEpicResourceLabelEvent`
+  + :class:`gitlab.v4.objects.GroupEpicResourceLabelEventManager`
+  + :attr:`gitlab.v4.objects.GroupEpic.resourcelabelevents`
+
+* GitLab API: https://docs.gitlab.com/api/resource_label_events
+
+Examples
+--------
 
-Update a label for a project:
+Get the events for a resource (issue, merge request or epic)::
 
-.. literalinclude:: labels.py
-   :start-after: # update
-   :end-before: # end update
+    events = resource.resourcelabelevents.list(get_all=True)
 
-Delete a label for a project:
+Get a specific event for a resource::
 
-.. literalinclude:: labels.py
-   :start-after: # delete
-   :end-before: # end delete
+    event = resource.resourcelabelevents.get(event_id)
diff --git a/docs/gl_objects/member_roles.rst b/docs/gl_objects/member_roles.rst
new file mode 100644
index 000000000..1c4aa07c5
--- /dev/null
+++ b/docs/gl_objects/member_roles.rst
@@ -0,0 +1,71 @@
+############
+Member Roles
+############
+
+You can configure member roles at the instance-level (admin only), or 
+at group level.
+
+Instance-level member roles
+===========================
+
+This endpoint requires admin access.
+
+Reference
+---------
+
+* v4 API
+
+  + :class:`gitlab.v4.objects.MemberRole`
+  + :class:`gitlab.v4.objects.MemberRoleManager`
+  + :attr:`gitlab.Gitlab.member_roles`
+
+* GitLab API
+
+  + https://docs.gitlab.com/api/member_roles#manage-instance-member-roles
+
+Examples
+--------
+
+List member roles::
+
+    variables = gl.member_roles.list()
+
+Create a member role::
+
+    variable = gl.member_roles.create({'name': 'Custom Role', 'base_access_level': value})
+
+Remove a member role::
+
+    gl.member_roles.delete(member_role_id)
+
+Group member role
+=================
+
+Reference
+---------
+
+* v4 API
+
+  + :class:`gitlab.v4.objects.GroupMemberRole`
+  + :class:`gitlab.v4.objects.GroupMemberRoleManager`
+  + :attr:`gitlab.v4.objects.Group.member_roles`
+
+* GitLab API
+
+  + https://docs.gitlab.com/api/member_roles#manage-group-member-roles
+
+Examples
+--------
+
+List member roles::
+
+    member_roles = group.member_roles.list()
+
+Create a member role::
+
+    member_roles = group.member_roles.create({'name': 'Custom Role', 'base_access_level': value})
+
+Remove a member role::
+
+    gl.member_roles.delete(member_role_id)
+
diff --git a/docs/gl_objects/merge_request_approvals.rst b/docs/gl_objects/merge_request_approvals.rst
new file mode 100644
index 000000000..4f9d561bb
--- /dev/null
+++ b/docs/gl_objects/merge_request_approvals.rst
@@ -0,0 +1,165 @@
+################################
+Merge request approvals settings
+################################
+
+Merge request approvals can be defined at the group level, or the project level or at the merge request level.
+
+Group approval rules
+====================
+
+References
+----------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.GroupApprovalRule`
+  + :class:`gitlab.v4.objects.GroupApprovalRuleManager`
+
+* GitLab API: https://docs.gitlab.com/api/merge_request_approvals
+
+Examples
+--------
+
+List group-level MR approval rules::
+
+    group_approval_rules = group.approval_rules.list(get_all=True)
+
+Change group-level MR approval rule::
+
+    g_approval_rule = group.approval_rules.get(123)
+    g_approval_rule.user_ids = [234]
+    g_approval_rule.save()
+
+Create new group-level MR approval rule::
+
+    group.approval_rules.create({
+        "name": "my new approval rule",
+        "approvals_required": 2,
+        "rule_type": "regular",
+        "user_ids": [105],
+        "group_ids": [653, 654],
+    })
+
+
+Project approval rules
+======================
+
+References
+----------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectApproval`
+  + :class:`gitlab.v4.objects.ProjectApprovalManager`
+  + :class:`gitlab.v4.objects.ProjectApprovalRule`
+  + :class:`gitlab.v4.objects.ProjectApprovalRuleManager`
+  + :attr:`gitlab.v4.objects.Project.approvals`
+
+* GitLab API: https://docs.gitlab.com/api/merge_request_approvals
+
+Examples
+--------
+
+List project-level MR approval rules::
+
+    p_mras = project.approvalrules.list(get_all=True)
+
+Change project-level MR approval rule::
+
+    p_approvalrule.user_ids = [234]
+    p_approvalrule.save()
+
+Delete project-level MR approval rule::
+
+    p_approvalrule.delete()
+
+Get project-level MR approvals settings::
+
+    p_mras = project.approvals.get()
+
+Change project-level MR approvals settings::
+
+    p_mras.approvals_before_merge = 2
+    p_mras.save()
+
+
+Merge request approval rules
+============================
+
+References
+----------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectMergeRequestApproval`
+  + :class:`gitlab.v4.objects.ProjectMergeRequestApprovalManager`
+  + :attr:`gitlab.v4.objects.ProjectMergeRequest.approvals`
+  + :class:`gitlab.v4.objects.ProjectMergeRequestApprovalRule`
+  + :class:`gitlab.v4.objects.ProjectMergeRequestApprovalRuleManager`
+  + :attr:`gitlab.v4.objects.ProjectMergeRequest.approval_rules`
+  + :class:`gitlab.v4.objects.ProjectMergeRequestApprovalState`
+  + :class:`gitlab.v4.objects.ProjectMergeRequestApprovalStateManager`
+  + :attr:`gitlab.v4.objects.ProjectMergeRequest.approval_state`
+
+* GitLab API: https://docs.gitlab.com/api/merge_request_approvals
+
+Examples
+--------
+
+
+Get MR-level MR approvals settings::
+
+    p_mras = project.approvals.get()
+
+    mr_mras = mr.approvals.get()
+
+Get MR-level approval state::
+
+    mr_approval_state = mr.approval_state.get()
+
+Change MR-level MR approvals settings::
+
+    mr.approvals.set_approvers(approvals_required=1)
+    # or
+    mr_mras.approvals_required = 1
+    mr_mras.save()
+
+Create a new MR-level approval rule or change an existing MR-level approval rule::
+
+    mr.approvals.set_approvers(approvals_required = 1, approver_ids=[105],
+                               approver_group_ids=[653, 654],
+                               approval_rule_name="my MR custom approval rule")
+
+List MR-level MR approval rules::
+
+    mr.approval_rules.list(get_all=True)
+
+Get a single MR approval rule::
+
+    approval_rule_id = 123
+    mr_approvalrule = mr.approval_rules.get(approval_rule_id)
+
+Delete MR-level MR approval rule::
+
+    rules = mr.approval_rules.list(get_all=False)
+    rules[0].delete()
+
+    # or
+    mr.approval_rules.delete(approval_id)
+
+Change MR-level MR approval rule::
+
+    mr_approvalrule.user_ids = [105]
+    mr_approvalrule.approvals_required = 2
+    mr_approvalrule.group_ids = [653, 654]
+    mr_approvalrule.save()
+
+Create a MR-level MR approval rule::
+
+   mr.approval_rules.create({
+       "name": "my MR custom approval rule",
+       "approvals_required": 2,
+       "rule_type": "regular",
+       "user_ids": [105],
+       "group_ids": [653, 654],
+   })
diff --git a/docs/gl_objects/merge_requests.rst b/docs/gl_objects/merge_requests.rst
new file mode 100644
index 000000000..0bb861c72
--- /dev/null
+++ b/docs/gl_objects/merge_requests.rst
@@ -0,0 +1,254 @@
+.. _merge_requests_examples:
+
+##############
+Merge requests
+##############
+
+You can use merge requests to notify a project that a branch is ready for
+merging. The owner of the target projet can accept the merge request.
+
+Merge requests are linked to projects, but they can be listed globally or for
+groups.
+
+Group and global listing
+========================
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.GroupMergeRequest`
+  + :class:`gitlab.v4.objects.GroupMergeRequestManager`
+  + :attr:`gitlab.v4.objects.Group.mergerequests`
+  + :class:`gitlab.v4.objects.MergeRequest`
+  + :class:`gitlab.v4.objects.MergeRequestManager`
+  + :attr:`gitlab.Gitlab.mergerequests`
+
+* GitLab API: https://docs.gitlab.com/api/merge_requests
+
+Examples
+--------
+
+List the merge requests created by the user of the token on the GitLab server::
+
+    mrs = gl.mergerequests.list(get_all=True)
+
+List the merge requests available on the GitLab server::
+
+    mrs = gl.mergerequests.list(scope="all", get_all=True)
+
+List the merge requests for a group::
+
+    group = gl.groups.get('mygroup')
+    mrs = group.mergerequests.list(get_all=True)
+
+.. note::
+
+   It is not possible to edit or delete ``MergeRequest`` and
+   ``GroupMergeRequest`` objects. You need to create a ``ProjectMergeRequest``
+   object to apply changes::
+
+       mr = group.mergerequests.list(get_all=False)[0]
+       project = gl.projects.get(mr.project_id, lazy=True)
+       editable_mr = project.mergerequests.get(mr.iid, lazy=True)
+       editable_mr.title = updated_title
+       editable_mr.save()
+
+Project merge requests
+======================
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectMergeRequest`
+  + :class:`gitlab.v4.objects.ProjectMergeRequestManager`
+  + :attr:`gitlab.v4.objects.Project.mergerequests`
+
+* GitLab API: https://docs.gitlab.com/api/merge_requests
+
+Examples
+--------
+
+List MRs for a project::
+
+    mrs = project.mergerequests.list(get_all=True)
+
+You can filter and sort the returned list with the following parameters:
+
+* ``state``: state of the MR. It can be one of ``all``, ``merged``, ``opened``,
+   ``closed`` or ``locked``
+* ``order_by``: sort by ``created_at`` or ``updated_at``
+* ``sort``: sort order (``asc`` or ``desc``)
+
+You can find a full updated list of parameters here:
+https://docs.gitlab.com/api/merge_requests#list-merge-requests
+
+For example::
+
+    mrs = project.mergerequests.list(state='merged', order_by='updated_at', get_all=True)
+
+Get a single MR::
+
+    mr = project.mergerequests.get(mr_iid)
+
+Get MR reviewer details::
+
+    mr = project.mergerequests.get(mr_iid)
+    reviewers = mr.reviewer_details.list(get_all=True)
+
+Create a MR::
+
+    mr = project.mergerequests.create({'source_branch': 'cool_feature',
+                                       'target_branch': 'main',
+                                       'title': 'merge cool feature',
+                                       'labels': ['label1', 'label2']})
+
+    # Use a project MR description template
+    mr_description_template = project.merge_request_templates.get("Default")
+    mr = project.mergerequests.create({'source_branch': 'cool_feature',
+                                       'target_branch': 'main',
+                                       'title': 'merge cool feature',
+                                       'description': mr_description_template.content})
+
+Update a MR::
+
+    mr.description = 'New description'
+    mr.labels = ['foo', 'bar']
+    mr.save()
+
+Change the state of a MR (close or reopen)::
+
+    mr.state_event = 'close'  # or 'reopen'
+    mr.save()
+
+Delete a MR::
+
+    project.mergerequests.delete(mr_iid)
+    # or
+    mr.delete()
+
+Accept a MR::
+
+    mr.merge()
+
+Schedule a MR to merge after the pipeline(s) succeed::
+
+    mr.merge(merge_when_pipeline_succeeds=True)
+
+Cancel a MR from merging when the pipeline succeeds::
+
+    # Cancel a MR from being merged that had been previously set to
+    # 'merge_when_pipeline_succeeds=True'
+    mr.cancel_merge_when_pipeline_succeeds()
+
+List commits of a MR::
+
+    commits = mr.commits()
+
+List the changes of a MR::
+
+    changes = mr.changes()
+
+List issues related to this merge request::
+
+    related_issues = mr.related_issues()
+
+List issues that will close on merge::
+
+    mr.closes_issues()
+
+Subscribe to / unsubscribe from a MR::
+
+    mr.subscribe()
+    mr.unsubscribe()
+
+Mark a MR as todo::
+
+    mr.todo()
+
+List the diffs for a merge request::
+
+    diffs = mr.diffs.list(get_all=True)
+
+Get a diff for a merge request::
+
+    diff = mr.diffs.get(diff_id)
+
+Get time tracking stats::
+
+    time_stats = mr.time_stats()
+
+On recent versions of Gitlab the time stats are also returned as a merge
+request object attribute::
+
+    mr = project.mergerequests.get(id)
+    print(mr.attributes['time_stats'])
+
+Set a time estimate for a merge request::
+
+    mr.time_estimate('3h30m')
+
+Reset a time estimate for a merge request::
+
+    mr.reset_time_estimate()
+
+Add spent time for a merge request::
+
+    mr.add_spent_time('3h30m')
+
+Reset spent time for a merge request::
+
+    mr.reset_spent_time()
+
+Get user agent detail for the issue (admin only)::
+
+    detail = issue.user_agent_detail()
+
+Attempt to rebase an MR::
+
+    mr.rebase()
+
+Clear all approvals of a merge request (possible with project or group access tokens only)::
+
+    mr.reset_approvals()
+
+Get status of a rebase for an MR::
+
+    mr = project.mergerequests.get(mr_id, include_rebase_in_progress=True)
+    print(mr.rebase_in_progress, mr.merge_error)
+
+For more info see:
+https://docs.gitlab.com/api/merge_requests#rebase-a-merge-request
+
+Attempt to merge changes between source and target branch::
+
+    response = mr.merge_ref()
+    print(response['commit_id'])
+
+Merge Request Pipelines
+=======================
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectMergeRequestPipeline`
+  + :class:`gitlab.v4.objects.ProjectMergeRequestPipelineManager`
+  + :attr:`gitlab.v4.objects.ProjectMergeRequest.pipelines`
+
+* GitLab API: https://docs.gitlab.com/api/merge_requests#list-mr-pipelines
+
+Examples
+--------
+
+List pipelines for a merge request::
+
+    pipelines = mr.pipelines.list(get_all=True)
+
+Create a pipeline for a merge request::
+
+    pipeline = mr.pipelines.create()
diff --git a/docs/gl_objects/merge_trains.rst b/docs/gl_objects/merge_trains.rst
new file mode 100644
index 000000000..6d98e04d8
--- /dev/null
+++ b/docs/gl_objects/merge_trains.rst
@@ -0,0 +1,29 @@
+############
+Merge Trains
+############
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectMergeTrain`
+  + :class:`gitlab.v4.objects.ProjectMergeTrainManager`
+  + :attr:`gitlab.v4.objects.Project.merge_trains`
+
+* GitLab API: https://docs.gitlab.com/api/merge_trains
+
+Examples
+--------
+
+List merge trains for a project::
+
+    merge_trains = project.merge_trains.list(get_all=True)
+
+List active merge trains for a project::
+
+    merge_trains = project.merge_trains.list(scope="active")
+
+List completed (have been merged) merge trains for a project::
+
+    merge_trains = project.merge_trains.list(scope="complete")
diff --git a/docs/gl_objects/messages.py b/docs/gl_objects/messages.py
deleted file mode 100644
index 74714e544..000000000
--- a/docs/gl_objects/messages.py
+++ /dev/null
@@ -1,23 +0,0 @@
-# list
-msgs = gl.broadcastmessages.list()
-# end list
-
-# get
-msg = gl.broadcastmessages.get(msg_id)
-# end get
-
-# create
-msg = gl.broadcastmessages.create({'message': 'Important information'})
-# end create
-
-# update
-msg.font = '#444444'
-msg.color = '#999999'
-msg.save()
-# end update
-
-# delete
-gl.broadcastmessages.delete(msg_id)
-# or
-msg.delete()
-# end delete
diff --git a/docs/gl_objects/messages.rst b/docs/gl_objects/messages.rst
index 9f183baf0..a7dbabbe7 100644
--- a/docs/gl_objects/messages.rst
+++ b/docs/gl_objects/messages.rst
@@ -6,41 +6,43 @@ You can use broadcast messages to display information on all pages of the
 gitlab web UI. You must have administration permissions to manipulate broadcast
 messages.
 
-* Object class: :class:`gitlab.objects.BroadcastMessage`
-* Manager object: :attr:`gitlab.Gitlab.broadcastmessages`
+References
+----------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.BroadcastMessage`
+  + :class:`gitlab.v4.objects.BroadcastMessageManager`
+  + :attr:`gitlab.Gitlab.broadcastmessages`
+
+* GitLab API: https://docs.gitlab.com/api/broadcast_messages
 
 Examples
 --------
 
-List the messages:
+List the messages::
 
-.. literalinclude:: messages.py
-   :start-after: # list
-   :end-before: # end list
+    msgs = gl.broadcastmessages.list(get_all=True)
 
-Get a single message:
+Get a single message::
 
-.. literalinclude:: messages.py
-   :start-after: # get
-   :end-before: # end get
+    msg = gl.broadcastmessages.get(msg_id)
 
-Create a message:
+Create a message::
 
-.. literalinclude:: messages.py
-   :start-after: # create
-   :end-before: # end create
+    msg = gl.broadcastmessages.create({'message': 'Important information'})
 
-The date format for ``starts_at`` and ``ends_at`` parameters is
+The date format for the ``starts_at`` and ``ends_at`` parameters is
 ``YYYY-MM-ddThh:mm:ssZ``.
 
-Update a message:
+Update a message::
 
-.. literalinclude:: messages.py
-   :start-after: # update
-   :end-before: # end update
+    msg.font = '#444444'
+    msg.color = '#999999'
+    msg.save()
 
-Delete a message:
+Delete a message::
 
-.. literalinclude:: messages.py
-   :start-after: # delete
-   :end-before: # end delete
+    gl.broadcastmessages.delete(msg_id)
+    # or
+    msg.delete()
diff --git a/docs/gl_objects/milestones.py b/docs/gl_objects/milestones.py
deleted file mode 100644
index 27be57310..000000000
--- a/docs/gl_objects/milestones.py
+++ /dev/null
@@ -1,42 +0,0 @@
-# list
-milestones = gl.project_milestones.list(project_id=1)
-# or
-milestones = project.milestones.list()
-# end list
-
-# filter
-milestones = gl.project_milestones.list(project_id=1, state='closed')
-# or
-milestones = project.milestones.list(state='closed')
-# end filter
-
-# get
-milestone = gl.project_milestones.get(milestone_id, project_id=1)
-# or
-milestone = project.milestones.get(milestone_id)
-# end get
-
-# create
-milestone = gl.project_milestones.create({'title': '1.0'}, project_id=1)
-# or
-milestone = project.milestones.create({'title': '1.0'})
-# end create
-
-# update
-milestone.description = 'v 1.0 release'
-milestone.save()
-# end update
-
-# state
-# close a milestone
-milestone.state_event = 'close'
-milestone.save
-
-# activate a milestone
-milestone.state_event = 'activate'
-m.save()
-# end state
-
-# issues
-issues = milestone.issues()
-# end issues
diff --git a/docs/gl_objects/milestones.rst b/docs/gl_objects/milestones.rst
index db8327544..7a02859db 100644
--- a/docs/gl_objects/milestones.rst
+++ b/docs/gl_objects/milestones.rst
@@ -2,54 +2,108 @@
 Milestones
 ##########
 
-Use :class:`~gitlab.objects.ProjectMilestone` objects to manipulate milestones.
-The :attr:`gitlab.Gitlab.project_milestones` and :attr:`Project.milestones
-<gitlab.objects.Project.milestones>` manager objects provide helper functions.
+Project milestones
+==================
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectMilestone`
+  + :class:`gitlab.v4.objects.ProjectMilestoneManager`
+  + :attr:`gitlab.v4.objects.Project.milestones`
+
+  + :class:`gitlab.v4.objects.GroupMilestone`
+  + :class:`gitlab.v4.objects.GroupMilestoneManager`
+  + :attr:`gitlab.v4.objects.Group.milestones`
+
+* GitLab API:
+
+  + https://docs.gitlab.com/api/milestones
+  + https://docs.gitlab.com/api/group_milestones
 
 Examples
 --------
 
-List the milestones for a project:
+List the milestones for a project or a group::
 
-.. literalinclude:: milestones.py
-   :start-after: # list
-   :end-before: # end list
+    p_milestones = project.milestones.list(get_all=True)
+    g_milestones = group.milestones.list(get_all=True)
 
 You can filter the list using the following parameters:
 
-* ``iid``: unique ID of the milestone for the project
+* ``iids``: unique IDs of milestones for the project
 * ``state``: either ``active`` or ``closed``
+* ``search``: to search using a string
+
+::
+
+    p_milestones = project.milestones.list(state='closed', get_all=True)
+    g_milestones = group.milestones.list(state='active', get_all=True)
+
+Get a single milestone::
+
+    p_milestone = project.milestones.get(milestone_id)
+    g_milestone = group.milestones.get(milestone_id)
+
+Create a milestone::
+
+    milestone = project.milestones.create({'title': '1.0'})
+
+Edit a milestone::
 
-.. literalinclude:: milestones.py
-   :start-after: # filter
-   :end-before: # end filter
+    milestone.description = 'v 1.0 release'
+    milestone.save()
 
-Get a single milestone:
+Change the state of a milestone (activate / close)::
 
-.. literalinclude:: milestones.py
-   :start-after: # get
-   :end-before: # end get
+    # close a milestone
+    milestone.state_event = 'close'
+    milestone.save()
 
-Create a milestone:
+    # activate a milestone
+    milestone.state_event = 'activate'
+    milestone.save()
 
-.. literalinclude:: milestones.py
-   :start-after: # create
-   :end-before: # end create
+Promote a project milestone::
 
-Edit a milestone:
+    milestone.promote()
 
-.. literalinclude:: milestones.py
-   :start-after: # update
-   :end-before: # end update
+List the issues related to a milestone::
+
+    issues = milestone.issues()
+
+List the merge requests related to a milestone::
+
+    merge_requests = milestone.merge_requests()
+
+Milestone events
+================
+
+Resource milestone events keep track of what happens to GitLab issues and merge requests.
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectIssueResourceMilestoneEvent`
+  + :class:`gitlab.v4.objects.ProjectIssueResourceMilestoneEventManager`
+  + :attr:`gitlab.v4.objects.ProjectIssue.resourcemilestoneevents`
+  + :class:`gitlab.v4.objects.ProjectMergeRequestResourceMilestoneEvent`
+  + :class:`gitlab.v4.objects.ProjectMergeRequestResourceMilestoneEventManager`
+  + :attr:`gitlab.v4.objects.ProjectMergeRequest.resourcemilestoneevents`
+
+* GitLab API: https://docs.gitlab.com/api/resource_milestone_events
+
+Examples
+--------
 
-Change the state of a milestone (activate / close):
+Get milestones for a resource (issue, merge request)::
 
-.. literalinclude:: milestones.py
-   :start-after: # state
-   :end-before: # end state
+    milestones = resource.resourcemilestoneevents.list(get_all=True)
 
-List the issues related to a milestone:
+Get a specific milestone for a resource::
 
-.. literalinclude:: milestones.py
-   :start-after: # issues
-   :end-before: # end issues
+    milestone = resource.resourcemilestoneevents.get(milestone_id)
diff --git a/docs/gl_objects/mrs.py b/docs/gl_objects/mrs.py
deleted file mode 100644
index 021338dcc..000000000
--- a/docs/gl_objects/mrs.py
+++ /dev/null
@@ -1,73 +0,0 @@
-# list
-mrs = gl.project_mergerequests.list(project_id=1)
-# or
-mrs = project.mergerequests.list()
-# end list
-
-# filtered list
-mrs = project.mergerequests.list(state='merged', order_by='updated_at')
-# end filtered list
-
-# get
-mr = gl.project_mergerequests.get(mr_id, project_id=1)
-# or
-mr = project.mergerequests.get(mr_id)
-# end get
-
-# create
-mr = gl.project_mergerequests.create({'source_branch': 'cool_feature',
-                                      'target_branch': 'master',
-                                      'title': 'merge cool feature'},
-                                     project_id=1)
-# or
-mr = project.mergerequests.create({'source_branch': 'cool_feature',
-                                   'target_branch': 'master',
-                                   'title': 'merge cool feature'})
-# end create
-
-# update
-mr.description = 'New description'
-mr.save()
-# end update
-
-# state
-mr.state_event = 'close'  # or 'reopen'
-mr.save()
-# end state
-
-# delete
-gl.project_mergerequests.delete(mr_id, project_id=1)
-# or
-project.mergerequests.delete(mr_id)
-# or
-mr.delete()
-# end delete
-
-# merge
-mr.merge()
-# end merge
-
-# cancel
-mr.cancel_merge_when_build_succeeds()
-# end cancel
-
-# issues
-mr.closes_issues()
-# end issues
-
-# subscribe
-mr.subscribe()
-mr.unsubscribe()
-# end subscribe
-
-# todo
-mr.todo()
-# end todo
-
-# diff list
-diffs = mr.diffs.list()
-# end diff list
-
-# diff get
-diff = mr.diffs.get(diff_id)
-# end diff get
diff --git a/docs/gl_objects/mrs.rst b/docs/gl_objects/mrs.rst
deleted file mode 100644
index d6e10d30d..000000000
--- a/docs/gl_objects/mrs.rst
+++ /dev/null
@@ -1,105 +0,0 @@
-##############
-Merge requests
-##############
-
-You can use merge requests to notify a project that a branch is ready for
-merging. The owner of the target projet can accept the merge request.
-
-* Object class: :class:`~gitlab.objects.ProjectMergeRequest`
-* Manager objects: :attr:`gitlab.Gitlab.project_mergerequests`,
-  :attr:`Project.mergerequests <gitlab.objects.Project.mergerequests>`
-
-Examples
---------
-
-List MRs for a project:
-
-.. literalinclude:: mrs.py
-   :start-after: # list
-   :end-before: # end list
-
-You can filter and sort the returned list with the following parameters:
-
-* ``iid``: iid (unique ID for the project) of the MR
-* ``state``: state of the MR. It can be one of ``all``, ``merged``, '``opened``
-  or ``closed``
-* ``order_by``: sort by ``created_at`` or ``updated_at``
-* ``sort``: sort order (``asc`` or ``desc``)
-
-For example:
-
-.. literalinclude:: mrs.py
-   :start-after: # list
-   :end-before: # end list
-
-Get a single MR:
-
-.. literalinclude:: mrs.py
-   :start-after: # get
-   :end-before: # end get
-
-Create a MR:
-
-.. literalinclude:: mrs.py
-   :start-after: # create
-   :end-before: # end create
-
-Update a MR:
-
-.. literalinclude:: mrs.py
-   :start-after: # update
-   :end-before: # end update
-
-Change the state of a MR (close or reopen):
-
-.. literalinclude:: mrs.py
-   :start-after: # state
-   :end-before: # end state
-
-Delete a MR:
-
-.. literalinclude:: mrs.py
-   :start-after: # delete
-   :end-before: # end delete
-
-Accept a MR:
-
-.. literalinclude:: mrs.py
-   :start-after: # merge
-   :end-before: # end merge
-
-Cancel a MR when the build succeeds:
-
-.. literalinclude:: mrs.py
-   :start-after: # cancel
-   :end-before: # end cancel
-
-List issues that will close on merge:
-
-.. literalinclude:: mrs.py
-   :start-after: # issues
-   :end-before: # end issues
-
-Subscribe/unsubscribe a MR:
-
-.. literalinclude:: mrs.py
-   :start-after: # subscribe
-   :end-before: # end subscribe
-
-Mark a MR as todo:
-
-.. literalinclude:: mrs.py
-   :start-after: # todo
-   :end-before: # end todo
-
-List the diffs for a merge request:
-
-.. literalinclude:: mrs.py
-   :start-after: # diff list
-   :end-before: # end diff list
-
-Get a diff for a merge request:
-
-.. literalinclude:: mrs.py
-   :start-after: # diff get
-   :end-before: # end diff get
diff --git a/docs/gl_objects/namespaces.py b/docs/gl_objects/namespaces.py
deleted file mode 100644
index fe5069757..000000000
--- a/docs/gl_objects/namespaces.py
+++ /dev/null
@@ -1,7 +0,0 @@
-# list
-namespaces = gl.namespaces.list()
-# end list
-
-# search
-namespaces = gl.namespaces.list(search='foo')
-# end search
diff --git a/docs/gl_objects/namespaces.rst b/docs/gl_objects/namespaces.rst
index 1819180b9..7c8eeb5e6 100644
--- a/docs/gl_objects/namespaces.rst
+++ b/docs/gl_objects/namespaces.rst
@@ -2,20 +2,36 @@
 Namespaces
 ##########
 
-Use :class:`~gitlab.objects.Namespace` objects to manipulate namespaces. The
-:attr:`gitlab.Gitlab.namespaces` manager objects provides helper functions.
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.Namespace`
+  + :class:`gitlab.v4.objects.NamespaceManager`
+  + :attr:`gitlab.Gitlab.namespaces`
+
+* GitLab API: https://docs.gitlab.com/api/namespaces
 
 Examples
-========
+--------
+
+List namespaces::
+
+    namespaces = gl.namespaces.list(get_all=True)
+
+Search namespaces::
+
+    namespaces = gl.namespaces.list(search='foo', get_all=True)
+
+Get a namespace by ID or path::
 
-List namespaces:
+  namespace = gl.namespaces.get("my-namespace")
 
-.. literalinclude:: namespaces.py
-   :start-after: # list
-   :end-before: # end list
+Get existence of a namespace by path::
 
-Search namespaces:
+  namespace = gl.namespaces.exists("new-namespace")
 
-.. literalinclude:: namespaces.py
-   :start-after: # search
-   :end-before: # end search
+  if namespace.exists:
+      # get suggestions of namespaces that don't already exist
+      print(namespace.suggests)
diff --git a/docs/gl_objects/notes.rst b/docs/gl_objects/notes.rst
new file mode 100644
index 000000000..d9c3d6824
--- /dev/null
+++ b/docs/gl_objects/notes.rst
@@ -0,0 +1,72 @@
+.. _project-notes:
+
+#####
+Notes
+#####
+
+You can manipulate notes (comments) on group epics, project issues, merge requests and
+snippets.
+
+Reference
+---------
+
+* v4 API:
+
+  Epics:
+
+  * :class:`gitlab.v4.objects.GroupEpicNote`
+  * :class:`gitlab.v4.objects.GroupEpicNoteManager`
+  * :attr:`gitlab.v4.objects.GroupEpic.notes`
+
+  Issues:
+
+  + :class:`gitlab.v4.objects.ProjectIssueNote`
+  + :class:`gitlab.v4.objects.ProjectIssueNoteManager`
+  + :attr:`gitlab.v4.objects.ProjectIssue.notes`
+
+  MergeRequests:
+
+  + :class:`gitlab.v4.objects.ProjectMergeRequestNote`
+  + :class:`gitlab.v4.objects.ProjectMergeRequestNoteManager`
+  + :attr:`gitlab.v4.objects.ProjectMergeRequest.notes`
+
+  Snippets:
+
+  + :class:`gitlab.v4.objects.ProjectSnippetNote`
+  + :class:`gitlab.v4.objects.ProjectSnippetNoteManager`
+  + :attr:`gitlab.v4.objects.ProjectSnippet.notes`
+
+* GitLab API: https://docs.gitlab.com/api/notes
+
+Examples
+--------
+
+List the notes for a resource::
+
+    e_notes = epic.notes.list(get_all=True)
+    i_notes = issue.notes.list(get_all=True)
+    mr_notes = mr.notes.list(get_all=True)
+    s_notes = snippet.notes.list(get_all=True)
+
+Get a note for a resource::
+
+    e_note = epic.notes.get(note_id)
+    i_note = issue.notes.get(note_id)
+    mr_note = mr.notes.get(note_id)
+    s_note = snippet.notes.get(note_id)
+
+Create a note for a resource::
+
+    e_note = epic.notes.create({'body': 'note content'})
+    i_note = issue.notes.create({'body': 'note content'})
+    mr_note = mr.notes.create({'body': 'note content'})
+    s_note = snippet.notes.create({'body': 'note content'})
+
+Update a note for a resource::
+
+    note.body = 'updated note content'
+    note.save()
+
+Delete a note for a resource::
+
+    note.delete()
diff --git a/docs/gl_objects/notifications.py b/docs/gl_objects/notifications.py
deleted file mode 100644
index c46e36eeb..000000000
--- a/docs/gl_objects/notifications.py
+++ /dev/null
@@ -1,21 +0,0 @@
-# get
-# global settings
-settings = gl.notificationsettings.get()
-# for a group
-settings = gl.groups.get(group_id).notificationsettings.get()
-# for a project
-settings = gl.projects.get(project_id).notificationsettings.get()
-# end get
-
-# update
-# use a predefined level
-settings.level = gitlab.NOTIFICATION_LEVEL_WATCH
-# create a custom setup
-settings.level = gitlab.NOTIFICATION_LEVEL_CUSTOM
-settings.save()  # will create additional attributes, but not mandatory
-
-settings.new_merge_request = True
-settings.new_issue = True
-settings.new_note = True
-settings.save()
-# end update
diff --git a/docs/gl_objects/notifications.rst b/docs/gl_objects/notifications.rst
index 472f710e9..3c5c7bd33 100644
--- a/docs/gl_objects/notifications.rst
+++ b/docs/gl_objects/notifications.rst
@@ -5,34 +5,55 @@ Notification settings
 You can define notification settings globally, for groups and for projects.
 Valid levels are defined as constants:
 
-* ``NOTIFICATION_LEVEL_DISABLED``
-* ``NOTIFICATION_LEVEL_PARTICIPATING``
-* ``NOTIFICATION_LEVEL_WATCH``
-* ``NOTIFICATION_LEVEL_GLOBAL``
-* ``NOTIFICATION_LEVEL_MENTION``
-* ``NOTIFICATION_LEVEL_CUSTOM``
+* ``gitlab.const.NotificationLevel.DISABLED``
+* ``gitlab.const.NotificationLevel.PARTICIPATING``
+* ``gitlab.const.NotificationLevel.WATCH``
+* ``gitlab.const.NotificationLevel.GLOBAL``
+* ``gitlab.const.NotificationLevel.MENTION``
+* ``gitlab.const.NotificationLevel.CUSTOM``
 
 You get access to fine-grained settings if you use the
 ``NOTIFICATION_LEVEL_CUSTOM`` level.
 
-* Object classes: :class:`gitlab.objects.NotificationSettings` (global),
-  :class:`gitlab.objects.GroupNotificationSettings` (groups) and
-  :class:`gitlab.objects.ProjectNotificationSettings` (projects)
-* Manager objects: :attr:`gitlab.Gitlab.notificationsettings` (global),
-  :attr:`gitlab.objects.Group.notificationsettings` (groups) and
-  :attr:`gitlab.objects.Project.notificationsettings` (projects)
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.NotificationSettings`
+  + :class:`gitlab.v4.objects.NotificationSettingsManager`
+  + :attr:`gitlab.Gitlab.notificationsettings`
+  + :class:`gitlab.v4.objects.GroupNotificationSettings`
+  + :class:`gitlab.v4.objects.GroupNotificationSettingsManager`
+  + :attr:`gitlab.v4.objects.Group.notificationsettings`
+  + :class:`gitlab.v4.objects.ProjectNotificationSettings`
+  + :class:`gitlab.v4.objects.ProjectNotificationSettingsManager`
+  + :attr:`gitlab.v4.objects.Project.notificationsettings`
+
+* GitLab API: https://docs.gitlab.com/api/notification_settings
 
 Examples
 --------
 
-Get the settings:
+Get the notifications settings::
+
+    # global settings
+    settings = gl.notificationsettings.get()
+    # for a group
+    settings = gl.groups.get(group_id).notificationsettings.get()
+    # for a project
+    settings = gl.projects.get(project_id).notificationsettings.get()
+
+Update the notifications settings::
 
-.. literalinclude:: notifications.py
-   :start-after: # get
-   :end-before: # end get
+    # use a predefined level
+    settings.level = gitlab.const.NotificationLevel.WATCH
 
-Update the settings:
+    # create a custom setup
+    settings.level = gitlab.const.NotificationLevel.CUSTOM
+    settings.save()  # will create additional attributes, but not mandatory
 
-.. literalinclude:: notifications.py
-   :start-after: # update
-   :end-before: # end update
+    settings.new_merge_request = True
+    settings.new_issue = True
+    settings.new_note = True
+    settings.save()
diff --git a/docs/gl_objects/packages.rst b/docs/gl_objects/packages.rst
new file mode 100644
index 000000000..369f8f9f4
--- /dev/null
+++ b/docs/gl_objects/packages.rst
@@ -0,0 +1,159 @@
+########
+Packages
+########
+
+Packages allow you to utilize GitLab as a private repository for a variety
+of common package managers, as well as GitLab's generic package registry.
+
+Project Packages
+=====================
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectPackage`
+  + :class:`gitlab.v4.objects.ProjectPackageManager`
+  + :attr:`gitlab.v4.objects.Project.packages`
+
+* GitLab API: https://docs.gitlab.com/api/packages#within-a-project
+
+Examples
+--------
+
+List the packages in a project::
+
+    packages = project.packages.list(get_all=True)
+
+Filter the results by ``package_type`` or ``package_name`` ::
+
+    packages = project.packages.list(package_type='pypi', get_all=True)
+
+Get a specific package of a project by id::
+
+    package = project.packages.get(1)
+
+Delete a package from a project::
+
+    package.delete()
+    # or
+    project.packages.delete(package.id)
+
+
+Group Packages
+===================
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.GroupPackage`
+  + :class:`gitlab.v4.objects.GroupPackageManager`
+  + :attr:`gitlab.v4.objects.Group.packages`
+
+* GitLab API: https://docs.gitlab.com/api/packages#within-a-group
+
+Examples
+--------
+
+List the packages in a group::
+
+    packages = group.packages.list(get_all=True)
+
+Filter the results by ``package_type`` or ``package_name`` ::
+
+    packages = group.packages.list(package_type='pypi', get_all=True)
+
+
+Project Package Files
+=====================
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectPackageFile`
+  + :class:`gitlab.v4.objects.ProjectPackageFileManager`
+  + :attr:`gitlab.v4.objects.ProjectPackage.package_files`
+
+* GitLab API: https://docs.gitlab.com/api/packages#list-package-files
+
+Examples
+--------
+
+List package files for package in project::
+
+    package = project.packages.get(1)
+    package_files = package.package_files.list(get_all=True)
+
+Delete a package file in a project::
+
+    package = project.packages.get(1)
+    file = package.package_files.list(get_all=False)[0]
+    file.delete()
+
+Project Package Pipelines
+=========================
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectPackagePipeline`
+  + :class:`gitlab.v4.objects.ProjectPackagePipelineManager`
+  + :attr:`gitlab.v4.objects.ProjectPackage.pipelines`
+
+* GitLab API: https://docs.gitlab.com/api/packages#list-package-pipelines
+
+Examples
+--------
+
+List package pipelines for package in project::
+
+    package = project.packages.get(1)
+    package_pipelines = package.pipelines.list(get_all=True)
+
+Generic Packages
+================
+
+You can use python-gitlab to upload and download generic packages.
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.GenericPackage`
+  + :class:`gitlab.v4.objects.GenericPackageManager`
+  + :attr:`gitlab.v4.objects.Project.generic_packages`
+
+* GitLab API: https://docs.gitlab.com/user/packages/generic_packages
+
+Examples
+--------
+
+Upload a generic package to a project::
+
+    project = gl.projects.get(1, lazy=True)
+    package = project.generic_packages.upload(
+        package_name="hello-world",
+        package_version="v1.0.0",
+        file_name="hello.tar.gz",
+        path="/path/to/local/hello.tar.gz"
+    )
+
+Download a project's generic package::
+
+    project = gl.projects.get(1, lazy=True)
+    package = project.generic_packages.download(
+        package_name="hello-world",
+        package_version="v1.0.0",
+        file_name="hello.tar.gz",
+    )
+
+.. hint:: You can use the Packages API described above to find packages and
+    retrieve the metadata you need download them.
diff --git a/docs/gl_objects/pagesdomains.rst b/docs/gl_objects/pagesdomains.rst
new file mode 100644
index 000000000..85887cf02
--- /dev/null
+++ b/docs/gl_objects/pagesdomains.rst
@@ -0,0 +1,94 @@
+#######################
+Pages and Pages domains
+#######################
+
+Project pages
+=============
+
+References
+----------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectPages`
+  + :class:`gitlab.v4.objects.ProjectPagesManager`
+  + :attr:`gitlab.v4.objects.Project.pages`
+
+* GitLab API: https://docs.gitlab.com/api/pages
+
+Examples
+--------
+
+Get Pages settings for a project::
+
+    pages = project.pages.get()
+
+Update Pages settings for a project::
+
+    project.pages.update(new_data={'pages_https_only': True})
+
+Delete (unpublish) Pages for a project (admin only)::
+
+    project.pages.delete()
+
+Pages domains (admin only)
+==========================
+
+References
+----------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.PagesDomain`
+  + :class:`gitlab.v4.objects.PagesDomainManager`
+  + :attr:`gitlab.Gitlab.pagesdomains`
+
+* GitLab API: https://docs.gitlab.com/api/pages_domains#list-all-pages-domains
+
+Examples
+--------
+
+List all the existing domains (admin only)::
+
+    domains = gl.pagesdomains.list(get_all=True)
+
+Project Pages domains
+=====================
+
+References
+----------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectPagesDomain`
+  + :class:`gitlab.v4.objects.ProjectPagesDomainManager`
+  + :attr:`gitlab.v4.objects.Project.pagesdomains`
+
+* GitLab API: https://docs.gitlab.com/api/pages_domains#list-pages-domains
+
+Examples
+--------
+
+List domains for a project::
+
+    domains = project.pagesdomains.list(get_all=True)
+
+Get a single domain::
+
+    domain = project.pagesdomains.get('d1.example.com')
+
+Create a new domain::
+
+    domain = project.pagesdomains.create({'domain': 'd2.example.com})
+
+Update an existing domain::
+
+    domain.certificate = open('d2.crt').read()
+    domain.key = open('d2.key').read()
+    domain.save()
+
+Delete an existing domain::
+
+    domain.delete
+    # or
+    project.pagesdomains.delete('d2.example.com')
diff --git a/docs/gl_objects/personal_access_tokens.rst b/docs/gl_objects/personal_access_tokens.rst
new file mode 100644
index 000000000..d9d54b596
--- /dev/null
+++ b/docs/gl_objects/personal_access_tokens.rst
@@ -0,0 +1,78 @@
+######################
+Personal Access Tokens
+######################
+
+References
+----------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.PersonalAccessToken`
+  + :class:`gitlab.v4.objects.PersonalAcessTokenManager`
+  + :attr:`gitlab.Gitlab.personal_access_tokens`
+  + :class:`gitlab.v4.objects.UserPersonalAccessToken`
+  + :class:`gitlab.v4.objects.UserPersonalAcessTokenManager`
+  + :attr:`gitlab.Gitlab.User.personal_access_tokens`
+
+* GitLab API:
+
+  + https://docs.gitlab.com/api/personal_access_tokens
+  + https://docs.gitlab.com/api/users#create-a-personal-access-token
+
+Examples
+--------
+
+List personal access tokens::
+
+    access_tokens = gl.personal_access_tokens.list(get_all=True)
+    print(access_tokens[0].name)
+
+List personal access tokens from other user_id (admin only)::
+
+    access_tokens = gl.personal_access_tokens.list(user_id=25, get_all=True)
+
+Get a personal access token by id::
+
+    gl.personal_access_tokens.get(123)
+
+Get the personal access token currently used::
+
+    gl.personal_access_tokens.get("self")
+
+Revoke a personal access token fetched via list::
+
+    access_token = access_tokens[0]
+    access_token.delete()
+
+Revoke a personal access token by id::
+
+    gl.personal_access_tokens.delete(123)
+
+Revoke the personal access token currently used::
+
+    gl.personal_access_tokens.delete("self")
+
+Rotate a personal access token and retrieve its new value::
+
+    token = gl.personal_access_tokens.get(42, lazy=True)
+    token.rotate()
+    print(token.token)
+    # or directly using a token ID
+    new_token_dict = gl.personal_access_tokens.rotate(42)
+    print(new_token_dict)
+
+Self-Rotate the personal access token you are using to authenticate the request and retrieve its new value::
+
+    token = gl.personal_access_tokens.get(42, lazy=True)
+    token.rotate(self_rotate=True)
+    print(token.token)
+
+Create a personal access token for a user (admin only)::
+
+    user = gl.users.get(25, lazy=True)
+    access_token = user.personal_access_tokens.create({"name": "test", "scopes": "api"})
+
+.. note:: As you can see above, you can only create personal access tokens
+    via the Users API, but you cannot revoke these objects directly.
+    This is because the create API uses a different endpoint than the list and revoke APIs.
+    You need to fetch the token via the list or get API first to revoke it.
diff --git a/docs/gl_objects/pipelines_and_jobs.rst b/docs/gl_objects/pipelines_and_jobs.rst
new file mode 100644
index 000000000..8b533b407
--- /dev/null
+++ b/docs/gl_objects/pipelines_and_jobs.rst
@@ -0,0 +1,407 @@
+##################
+Pipelines and Jobs
+##################
+
+Project pipelines
+=================
+
+A pipeline is a group of jobs executed by GitLab CI.
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectPipeline`
+  + :class:`gitlab.v4.objects.ProjectPipelineManager`
+  + :attr:`gitlab.v4.objects.Project.pipelines`
+
+* GitLab API: https://docs.gitlab.com/api/pipelines
+
+Examples
+--------
+
+List pipelines for a project::
+
+    pipelines = project.pipelines.list(get_all=True)
+
+Get a pipeline for a project::
+
+    pipeline = project.pipelines.get(pipeline_id)
+
+Get variables of a pipeline::
+
+    variables = pipeline.variables.list(get_all=True)
+
+Create a pipeline for a particular reference with custom variables::
+
+    pipeline = project.pipelines.create({'ref': 'main', 'variables': [{'key': 'MY_VARIABLE', 'value': 'hello'}]})
+
+Retry the failed builds for a pipeline::
+
+    pipeline.retry()
+
+Cancel builds in a pipeline::
+
+    pipeline.cancel()
+
+Delete a pipeline::
+
+    pipeline.delete()
+
+Get latest pipeline::
+
+    project.pipelines.latest(ref="main")
+
+
+Triggers
+========
+
+Triggers provide a way to interact with the GitLab CI. Using a trigger a user
+or an application can run a new build/job for a specific commit.
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectTrigger`
+  + :class:`gitlab.v4.objects.ProjectTriggerManager`
+  + :attr:`gitlab.v4.objects.Project.triggers`
+
+* GitLab API: https://docs.gitlab.com/api/pipeline_triggers
+
+Examples
+--------
+
+List triggers::
+
+    triggers = project.triggers.list(get_all=True)
+
+Get a trigger::
+
+    trigger = project.triggers.get(trigger_token)
+
+Create a trigger::
+
+    trigger = project.triggers.create({'description': 'mytrigger'})
+
+Remove a trigger::
+
+    project.triggers.delete(trigger_token)
+    # or
+    trigger.delete()
+
+Full example with wait for finish::
+
+    def get_or_create_trigger(project):
+        trigger_decription = 'my_trigger_id'
+        for t in project.triggers.list(iterator=True):
+            if t.description == trigger_decription:
+                return t
+        return project.triggers.create({'description': trigger_decription})
+
+    trigger = get_or_create_trigger(project)
+    pipeline = project.trigger_pipeline('main', trigger.token, variables={"DEPLOY_ZONE": "us-west1"})
+    while pipeline.finished_at is None:
+        pipeline.refresh()
+        time.sleep(1)
+
+You can trigger a pipeline using token authentication instead of user
+authentication. To do so create an anonymous Gitlab instance and use lazy
+objects to get the associated project::
+
+    gl = gitlab.Gitlab(URL)  # no authentication
+    project = gl.projects.get(project_id, lazy=True)  # no API call
+    project.trigger_pipeline('main', trigger_token)
+
+Reference: https://docs.gitlab.com/ci/triggers/#trigger-token
+
+Pipeline schedules
+==================
+
+You can schedule pipeline runs using a cron-like syntax. Variables can be
+associated with the scheduled pipelines.
+
+Reference
+---------
+
+* v4 API
+
+  + :class:`gitlab.v4.objects.ProjectPipelineSchedule`
+  + :class:`gitlab.v4.objects.ProjectPipelineScheduleManager`
+  + :attr:`gitlab.v4.objects.Project.pipelineschedules`
+  + :class:`gitlab.v4.objects.ProjectPipelineScheduleVariable`
+  + :class:`gitlab.v4.objects.ProjectPipelineScheduleVariableManager`
+  + :attr:`gitlab.v4.objects.ProjectPipelineSchedule.variables`
+  + :class:`gitlab.v4.objects.ProjectPipelineSchedulePipeline`
+  + :class:`gitlab.v4.objects.ProjectPipelineSchedulePipelineManager`
+  + :attr:`gitlab.v4.objects.ProjectPipelineSchedule.pipelines`
+
+* GitLab API: https://docs.gitlab.com/api/pipeline_schedules
+
+Examples
+--------
+
+List pipeline schedules::
+
+    scheds = project.pipelineschedules.list(get_all=True)
+
+Get a single schedule::
+
+    sched = project.pipelineschedules.get(schedule_id)
+
+Create a new schedule::
+
+    sched = project.pipelineschedules.create({
+        'ref': 'main',
+        'description': 'Daily test',
+        'cron': '0 1 * * *'})
+
+Update a schedule::
+
+    sched.cron = '1 2 * * *'
+    sched.save()
+
+Take ownership of a schedule:
+
+    sched.take_ownership()
+
+Trigger a pipeline schedule immediately::
+
+    sched = projects.pipelineschedules.get(schedule_id)
+    sched.play()
+
+Delete a schedule::
+
+    sched.delete()
+
+List schedule variables::
+
+    # note: you need to use get() to retrieve the schedule variables. The
+    # attribute is not present in the response of a list() call
+    sched = projects.pipelineschedules.get(schedule_id)
+    vars = sched.attributes['variables']
+
+Create a schedule variable::
+
+    var = sched.variables.create({'key': 'foo', 'value': 'bar'})
+
+Edit a schedule variable::
+
+    var.value = 'new_value'
+    var.save()
+
+Delete a schedule variable::
+
+    var.delete()
+
+List all pipelines triggered by a pipeline schedule::
+
+    pipelines = sched.pipelines.list(get_all=True)
+
+Jobs
+====
+
+Jobs are associated to projects, pipelines and commits. They provide
+information on the jobs that have been run, and methods to manipulate
+them.
+
+Reference
+---------
+
+* v4 API
+
+  + :class:`gitlab.v4.objects.ProjectJob`
+  + :class:`gitlab.v4.objects.ProjectJobManager`
+  + :attr:`gitlab.v4.objects.Project.jobs`
+
+* GitLab API: https://docs.gitlab.com/api/jobs
+
+Examples
+--------
+
+Jobs are usually automatically triggered, but you can explicitly trigger a new
+job::
+
+    project.trigger_build('main', trigger_token,
+                          {'extra_var1': 'foo', 'extra_var2': 'bar'})
+
+List jobs for the project::
+
+    jobs = project.jobs.list(get_all=True)
+
+Get a single job::
+
+    project.jobs.get(job_id)
+
+List the jobs of a pipeline::
+
+    project = gl.projects.get(project_id)
+    pipeline = project.pipelines.get(pipeline_id)
+    jobs = pipeline.jobs.list(get_all=True)
+
+.. note::
+
+   Job methods (play, cancel, and so on) are not available on
+   ``ProjectPipelineJob`` objects. To use these methods create a ``ProjectJob``
+   object::
+
+       pipeline_job = pipeline.jobs.list(get_all=False)[0]
+       job = project.jobs.get(pipeline_job.id, lazy=True)
+       job.retry()
+
+Get the artifacts of a job::
+
+    build_or_job.artifacts()
+
+Get the artifacts of a job by its name from the latest successful pipeline of
+a branch or tag::
+
+  project.artifacts.download(ref_name='main', job='build')
+
+.. warning::
+
+   Artifacts are entirely stored in memory in this example.
+
+.. _streaming_example:
+
+You can download artifacts as a stream. Provide a callable to handle the
+stream::
+
+    with open("archive.zip", "wb") as f:
+         build_or_job.artifacts(streamed=True, action=f.write)
+
+You can also directly stream the output into a file, and unzip it afterwards::
+
+    zipfn = "___artifacts.zip"
+    with open(zipfn, "wb") as f:
+        build_or_job.artifacts(streamed=True, action=f.write)
+    subprocess.run(["unzip", "-bo", zipfn])
+    os.unlink(zipfn)
+
+Or, you can also use the underlying response iterator directly::
+
+    artifact_bytes_iterator = build_or_job.artifacts(iterator=True)
+
+This can be used with frameworks that expect an iterator (such as FastAPI/Starlette's
+``StreamingResponse``) to forward a download from GitLab without having to download
+the entire content server-side first::
+
+    @app.get("/download_artifact")
+    def download_artifact():
+        artifact_bytes_iterator = build_or_job.artifacts(iterator=True)
+        return StreamingResponse(artifact_bytes_iterator, media_type="application/zip")
+
+Delete all artifacts of a project that can be deleted::
+
+  project.artifacts.delete()
+
+Get a single artifact file::
+
+    build_or_job.artifact('path/to/file')
+
+Get a single artifact file by branch and job::
+
+    project.artifacts.raw('branch', 'path/to/file', 'job')
+
+Mark a job artifact as kept when expiration is set::
+
+    build_or_job.keep_artifacts()
+
+Delete the artifacts of a job::
+
+    build_or_job.delete_artifacts()
+
+Get a job log file / trace::
+
+    build_or_job.trace()
+
+.. warning::
+
+   Traces are entirely stored in memory unless you use the streaming feature.
+   See :ref:`the artifacts example <streaming_example>`.
+
+Cancel/retry a job::
+
+    build_or_job.cancel()
+    build_or_job.retry()
+
+Play (trigger) a job::
+
+    build_or_job.play()
+
+Erase a job (artifacts and trace)::
+
+    build_or_job.erase()
+
+
+Pipeline bridges
+=====================
+
+Get a list of bridge jobs (including child pipelines) for a pipeline.
+
+Reference
+---------
+
+* v4 API
+
+  + :class:`gitlab.v4.objects.ProjectPipelineBridge`
+  + :class:`gitlab.v4.objects.ProjectPipelineBridgeManager`
+  + :attr:`gitlab.v4.objects.ProjectPipeline.bridges`
+
+* GitLab API: https://docs.gitlab.com/api/jobs#list-pipeline-bridges
+
+Examples
+--------
+
+List bridges for the pipeline::
+
+    bridges = pipeline.bridges.list(get_all=True)
+
+Pipeline test report
+====================
+
+Get a pipeline's complete test report.
+
+Reference
+---------
+
+* v4 API
+
+  + :class:`gitlab.v4.objects.ProjectPipelineTestReport`
+  + :class:`gitlab.v4.objects.ProjectPipelineTestReportManager`
+  + :attr:`gitlab.v4.objects.ProjectPipeline.test_report`
+
+* GitLab API: https://docs.gitlab.com/api/pipelines#get-a-pipelines-test-report
+
+Examples
+--------
+
+Get the test report for a pipeline::
+
+    test_report = pipeline.test_report.get()
+
+Pipeline test report summary
+============================
+
+Get a pipeline’s test report summary.
+
+Reference
+---------
+
+* v4 API
+
+  + :class:`gitlab.v4.objects.ProjectPipelineTestReportSummary`
+  + :class:`gitlab.v4.objects.ProjectPipelineTestReportSummaryManager`
+  + :attr:`gitlab.v4.objects.ProjectPipeline.test_report_summary`
+
+* GitLab API: https://docs.gitlab.com/api/pipelines#get-a-pipelines-test-report-summary
+
+Examples
+--------
+
+Get the test report summary for a pipeline::
+
+    test_report_summary = pipeline.test_report_summary.get()
+
diff --git a/docs/gl_objects/project_access_tokens.rst b/docs/gl_objects/project_access_tokens.rst
new file mode 100644
index 000000000..6088e4d55
--- /dev/null
+++ b/docs/gl_objects/project_access_tokens.rst
@@ -0,0 +1,54 @@
+#####################
+Project Access Tokens
+#####################
+
+Get a list of project access tokens
+
+References
+----------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectAccessToken`
+  + :class:`gitlab.v4.objects.ProjectAccessTokenManager`
+  + :attr:`gitlab.Gitlab.project_access_tokens`
+
+* GitLab API: https://docs.gitlab.com/api/project_access_tokens
+
+Examples
+--------
+
+List project access tokens::
+
+    access_tokens = gl.projects.get(1, lazy=True).access_tokens.list(get_all=True)
+    print(access_tokens[0].name)
+
+Get a project access token by id::
+
+    token = project.access_tokens.get(123)
+    print(token.name)
+
+Create project access token::
+
+    access_token = gl.projects.get(1).access_tokens.create({"name": "test", "scopes": ["api"], "expires_at": "2023-06-06"})
+
+Revoke a project access token::
+
+    gl.projects.get(1).access_tokens.delete(42)
+    # or
+    access_token.delete()
+
+Rotate a project access token and retrieve its new value::
+
+    token = project.access_tokens.get(42, lazy=True)
+    token.rotate()
+    print(token.token)
+    # or directly using a token ID
+    new_token = project.access_tokens.rotate(42)
+    print(new_token.token)
+
+Self-Rotate the project access token you are using to authenticate the request and retrieve its new value::
+
+    token = project.access_tokens.get(42, lazy=True)
+    token.rotate(self_rotate=True)
+    print(new_token.token)
\ No newline at end of file
diff --git a/docs/gl_objects/projects.py b/docs/gl_objects/projects.py
deleted file mode 100644
index ed99cec44..000000000
--- a/docs/gl_objects/projects.py
+++ /dev/null
@@ -1,445 +0,0 @@
-# list
-# Active projects
-projects = gl.projects.list()
-# Archived projects
-projects = gl.projects.list(archived=1)
-# Limit to projects with a defined visibility
-projects = gl.projects.list(visibility='public')
-
-# List owned projects
-projects = gl.projects.owned()
-
-# List starred projects
-projects = gl.projects.starred()
-
-# List all the projects
-projects = gl.projects.all()
-
-# Search projects
-projects = gl.projects.search('query')
-# end list
-
-# get
-# Get a project by ID
-project = gl.projects.get(10)
-# Get a project by userspace/name
-project = gl.projects.get('myteam/myproject')
-# end get
-
-# create
-project = gl.projects.create({'name': 'project1'})
-# end create
-
-# user create
-alice gl.users.list(username='alice')[0]
-user_project = gl.user_projects.create({'name': 'project',
-                                        'user_id': alice.id})
-# end user create
-
-# update
-project.snippets_enabled = 1
-project.save()
-# end update
-
-# delete
-gl.projects.delete(1)
-# or
-project.delete()
-# end delete
-
-# fork
-fork = gl.project_forks.create({}, project_id=1)
-# or
-fork = project.forks.create({})
-
-# fork to a specific namespace
-fork = gl.project_forks.create({'namespace': 'myteam'}, project_id=1)
-# end fork
-
-# forkrelation
-project.create_fork_relation(source_project.id)
-project.delete_fork_relation()
-# end forkrelation
-
-# star
-project.star()
-project.unstar()
-# end star
-
-# archive
-project.archive_()
-project.unarchive_()
-# end archive
-
-# events list
-gl.project_events.list(project_id=1)
-# or
-project.events.list()
-# end events list
-
-# members list
-members = gl.project_members.list()
-# or
-members = project.members.list()
-# end members list
-
-# members search
-members = gl.project_members.list(query='foo')
-# or
-members = project.members.list(query='bar')
-# end members search
-
-# members get
-member = gl.project_members.get(1)
-# or
-member = project.members.get(1)
-# end members get
-
-# members add
-member = gl.project_members.create({'user_id': user.id, 'access_level':
-                                    gitlab.DEVELOPER_ACCESS},
-                                   project_id=1)
-# or
-member = project.members.create({'user_id': user.id, 'access_level':
-                                 gitlab.DEVELOPER_ACCESS})
-# end members add
-
-# members update
-member.access_level = gitlab.MASTER_ACCESS
-member.save()
-# end members update
-
-# members delete
-gl.project_members.delete(user.id, project_id=1)
-# or
-project.members.delete(user.id)
-# or
-member.delete()
-# end members delete
-
-# share
-project.share(group.id, gitlab.DEVELOPER_ACCESS)
-# end share
-
-# hook list
-hooks = gl.project_hooks.list(project_id=1)
-# or
-hooks = project.hooks.list()
-# end hook list
-
-# hook get
-hook = gl.project_hooks.get(1, project_id=1)
-# or
-hook = project.hooks.get(1)
-# end hook get
-
-# hook create
-hook = gl.project_hooks.create({'url': 'http://my/action/url',
-                                'push_events': 1},
-                               project_id=1)
-# or
-hook = project.hooks.create({'url': 'http://my/action/url', 'push_events': 1})
-# end hook create
-
-# hook update
-hook.push_events = 0
-hook.save()
-# end hook update
-
-# hook delete
-gl.project_hooks.delete(1, project_id=1)
-# or
-project.hooks.delete(1)
-# or
-hook.delete()
-# end hook delete
-
-# repository tree
-# list the content of the root directory for the default branch
-items = project.repository_tree()
-
-# list the content of a subdirectory on a specific branch
-items = project.repository_tree(path='docs', ref='branch1')
-# end repository tree
-
-# repository blob
-file_content = p.repository_blob('master', 'README.rst')
-# end repository blob
-
-# repository raw_blob
-# find the id for the blob (simple search)
-id = [d['id'] for d in p.repository_tree() if d['name'] == 'README.rst'][0]
-
-# get the content
-file_content = p.repository_raw_blob(id)
-# end repository raw_blob
-
-# repository compare
-result = project.repository_compare('master', 'branch1')
-
-# get the commits
-for i in commit:
-    print(result.commits)
-
-# get the diffs
-for file_diff in commit.diffs:
-    print(file_diff)
-# end repository compare
-
-# repository archive
-# get the archive for the default branch
-tgz = project.repository_archive()
-
-# get the archive for a branch/tag/commit
-tgz = project.repository_archive(sha='4567abc')
-# end repository archive
-
-# repository contributors
-contributors = project.repository_contributors()
-# end repository contributors
-
-# files get
-f = gl.project_files.get(file_path='README.rst', ref='master',
-                         project_id=1)
-# or
-f = project.files.get(file_path='README.rst', ref='master')
-
-# get the base64 encoded content
-print(f.content)
-
-# get the decoded content
-print(f.decode())
-# end files get
-
-# files create
-f = gl.project_files.create({'file_path': 'testfile',
-                             'branch_name': 'master',
-                             'content': file_content,
-                             'commit_message': 'Create testfile'},
-                            project_id=1)
-# or
-f = project.files.create({'file_path': 'testfile',
-                          'branch_name': 'master',
-                          'content': file_content,
-                          'commit_message': 'Create testfile'})
-# end files create
-
-# files update
-f.content = 'new content'
-f.save(branch_name='master', commit_message='Update testfile')
-
-# or for binary data
-# Note: decode() is required with python 3 for data serialization. You can omit
-# it with python 2
-f.content = base64.b64encode(open('image.png').read()).decode()
-f.save(branch_name='master', commit_message='Update testfile', encoding='base64')
-# end files update
-
-# files delete
-gl.project_files.delete({'file_path': 'testfile',
-                         'branch_name': 'master',
-                         'commit_message': 'Delete testfile'},
-                        project_id=1)
-# or
-project.files.delete({'file_path': 'testfile',
-                      'branch_name': 'master',
-                      'commit_message': 'Delete testfile'})
-# or
-f.delete(commit_message='Delete testfile')
-# end files delete
-
-# tags list
-tags = gl.project_tags.list(project_id=1)
-# or
-tags = project.tags.list()
-# end tags list
-
-# tags get
-tag = gl.project_tags.list('1.0', project_id=1)
-# or
-tags = project.tags.list('1.0')
-# end tags get
-
-# tags create
-tag = gl.project_tags.create({'tag_name': '1.0', 'ref': 'master'},
-                             project_id=1)
-# or
-tag = project.tags.create({'tag_name': '1.0', 'ref': 'master'})
-# end tags create
-
-# tags delete
-gl.project_tags.delete('1.0', project_id=1)
-# or
-project.tags.delete('1.0')
-# or
-tag.delete()
-# end tags delete
-
-# tags release
-tag.set_release_description('awesome v1.0 release')
-# end tags release
-
-# snippets list
-snippets = gl.project_snippets.list(project_id=1)
-# or
-snippets = project.snippets.list()
-# end snippets list
-
-# snippets get
-snippet = gl.project_snippets.list(snippet_id, project_id=1)
-# or
-snippets = project.snippets.list(snippet_id)
-# end snippets get
-
-# snippets create
-snippet = gl.project_snippets.create({'title': 'sample 1',
-                                      'file_name': 'foo.py',
-                                      'code': 'import gitlab',
-                                      'visibility_level':
-                                      gitlab.VISIBILITY_PRIVATE},
-                                     project_id=1)
-# or
-snippet = project.snippets.create({'title': 'sample 1',
-                                   'file_name': 'foo.py',
-                                   'code': 'import gitlab',
-                                   'visibility_level':
-                                   gitlab.VISIBILITY_PRIVATE})
-# end snippets create
-
-# snippets content
-print(snippet.content())
-# end snippets content
-
-# snippets update
-snippet.code = 'import gitlab\nimport whatever'
-snippet.save
-# end snippets update
-
-# snippets delete
-gl.project_snippets.delete(snippet_id, project_id=1)
-# or
-project.snippets.delete(snippet_id)
-# or
-snippet.delete()
-# end snippets delete
-
-# notes list
-i_notes = gl.project_issue_notes.list(project_id=1, issue_id=2)
-mr_notes = gl.project_mergerequest_notes.list(project_id=1, merge_request_id=2)
-s_notes = gl.project_snippet_notes.list(project_id=1, snippet_id=2)
-# or
-i_notes = issue.notes.list()
-mr_notes = mr.notes.list()
-s_notes = snippet.notes.list()
-# end notes list
-
-# notes get
-i_notes = gl.project_issue_notes.get(note_id, project_id=1, issue_id=2)
-mr_notes = gl.project_mergerequest_notes.get(note_id, project_id=1,
-                                             merge_request_id=2)
-s_notes = gl.project_snippet_notes.get(note_id, project_id=1, snippet_id=2)
-# or
-i_note = issue.notes.get(note_id)
-mr_note = mr.notes.get(note_id)
-s_note = snippet.notes.get(note_id)
-# end notes get
-
-# notes create
-i_note = gl.project_issue_notes.create({'body': 'note content'},
-                                       project_id=1, issue_id=2)
-mr_note = gl.project_mergerequest_notes.create({'body': 'note content'}
-                                               project_id=1,
-                                               merge_request_id=2)
-s_note = gl.project_snippet_notes.create({'body': 'note content'},
-                                          project_id=1, snippet_id=2)
-# or
-i_note = issue.notes.create({'body': 'note content'})
-mr_note = mr.notes.create({'body': 'note content'})
-s_note = snippet.notes.create({'body': 'note content'})
-# end notes create
-
-# notes update
-note.body = 'updated note content'
-note.save()
-# end notes update
-
-# notes delete
-note.delete()
-# end notes delete
-
-# service get
-service = gl.project_services.get(service_name='asana', project_id=1)
-# or
-service = project.services.get(service_name='asana', project_id=1)
-# display it's status (enabled/disabled)
-print(service.active)
-# end service get
-
-# service list
-services = gl.project_services.available()
-# end service list
-
-# service update
-service.api_key = 'randomkey'
-service.save()
-# end service update
-
-# service delete
-service.delete()
-# end service delete
-
-# pipeline list
-pipelines = gl.project_pipelines.list(project_id=1)
-# or
-pipelines = project.pipelines.list()
-# end pipeline list
-
-# pipeline get
-pipeline = gl.project_pipelines.get(pipeline_id, project_id=1)
-# or
-pipeline = project.pipelines.get(pipeline_id)
-# end pipeline get
-
-# pipeline retry
-pipeline.retry()
-# end pipeline retry
-
-# pipeline cancel
-pipeline.cancel()
-# end pipeline cancel
-
-# boards list
-boards = gl.project_boards.list(project_id=1)
-# or
-boards = project.boards.list()
-# end boards list
-
-# boards get
-board = gl.project_boards.get(board_id, project_id=1)
-# or
-board = project.boards.get(board_id)
-# end boards get
-
-# board lists list
-b_lists = board.lists.list()
-# end board lists list
-
-# board lists get
-b_list = board.lists.get(list_id)
-# end board lists get
-
-# board lists create
-# First get a ProjectLabel
-label = get_or_create_label()
-# Then use its ID to create the new board list
-b_list = board.lists.create({'label_id': label.id})
-# end board lists create
-
-# board lists update
-b_list.position = 2
-b_list.save()
-# end board lists update
-
-# board lists delete
-b_list.delete()
-# end boards lists delete
diff --git a/docs/gl_objects/projects.rst b/docs/gl_objects/projects.rst
index 584fa58f6..8305a6b0b 100644
--- a/docs/gl_objects/projects.rst
+++ b/docs/gl_objects/projects.rst
@@ -2,13 +2,26 @@
 Projects
 ########
 
-Use :class:`~gitlab.objects.Project` objects to manipulate projects. The
-:attr:`gitlab.Gitlab.projects` manager objects provides helper functions.
+Projects
+========
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.Project`
+  + :class:`gitlab.v4.objects.ProjectManager`
+  + :attr:`gitlab.Gitlab.projects`
+
+* GitLab API: https://docs.gitlab.com/api/projects
 
 Examples
-========
+--------
 
-List projects:
+List projects::
+
+    projects = gl.projects.list(get_all=True)
 
 The API provides several filtering parameters for the listing methods:
 
@@ -24,503 +37,872 @@ Results can also be sorted using the following parameters:
   The default is to sort by ``created_at``
 * ``sort``: sort order (``asc`` or ``desc``)
 
-.. literalinclude:: projects.py
-   :start-after: # list
-   :end-before: # end list
+::
 
-Get a single project:
+    # List all projects (default 20)
+    projects = gl.projects.list(get_all=True)
+    # Archived projects
+    projects = gl.projects.list(archived=1, get_all=True)
+    # Limit to projects with a defined visibility
+    projects = gl.projects.list(visibility='public', get_all=True)
 
-.. literalinclude:: projects.py
-   :start-after: # get
-   :end-before: # end get
+    # List owned projects
+    projects = gl.projects.list(owned=True, get_all=True)
 
-Create a project:
+    # List starred projects
+    projects = gl.projects.list(starred=True, get_all=True)
 
-.. literalinclude:: projects.py
-   :start-after: # create
-   :end-before: # end create
+    # Search projects
+    projects = gl.projects.list(search='keyword', get_all=True)
 
-Create a project for a user (admin only):
+.. note::
 
-.. literalinclude:: projects.py
-   :start-after: # user create
-   :end-before: # end user create
+   To list the starred projects of another user, see the
+   :ref:`Users API docs <users_examples>`.
 
-Create a project in a group:
+.. note::
 
-You need to get the id of the group, then use the namespace_id attribute to create the group:
+   Fetching a list of projects, doesn't include all attributes of all projects.
+   To retrieve all attributes, you'll need to fetch a single project
 
-.. code:: python
+Get a single project::
 
-  group_id = gl.groups.search('my-group')[0].id
-  project = gl.projects.create({'name': 'myrepo', 'namespace_id': group_id})
+    # Get a project by ID
+    project_id = 851
+    project = gl.projects.get(project_id)
 
+    # Get a project by name with namespace
+    project_name_with_namespace = "namespace/project_name"
+    project = gl.projects.get(project_name_with_namespace)
 
-Update a project:
+Create a project::
 
-.. literalinclude:: projects.py
-   :start-after: # update
-   :end-before: # end update
+    project = gl.projects.create({'name': 'project1'})
 
-Delete a project:
+Create a project for a user (admin only)::
 
-.. literalinclude:: projects.py
-   :start-after: # delete
-   :end-before: # end delete
+    alice = gl.users.list(username='alice', get_all=False)[0]
+    user_project = alice.projects.create({'name': 'project'})
+    user_projects = alice.projects.list(get_all=True)
 
-Fork a project:
+Create a project in a group::
 
-.. literalinclude:: projects.py
-   :start-after: # fork
-   :end-before: # end fork
+    # You need to get the id of the group, then use the namespace_id attribute
+    # to create the group
+    group_id = gl.groups.list(search='my-group', get_all=False)[0].id
+    project = gl.projects.create({'name': 'myrepo', 'namespace_id': group_id})
 
-Create/delete a fork relation between projects (requires admin permissions):
+List a project's groups::
 
-.. literalinclude:: projects.py
-   :start-after: # forkrelation
-   :end-before: # end forkrelation
+    # Get a list of ancestor/parent groups for a project.
+    groups = project.groups.list(get_all=True)
 
-Star/unstar a project:
+Update a project::
 
-.. literalinclude:: projects.py
-   :start-after: # star
-   :end-before: # end star
+    project.snippets_enabled = 1
+    project.save()
 
-Archive/unarchive a project:
+Set the avatar image for a project::
 
-.. literalinclude:: projects.py
-   :start-after: # archive
-   :end-before: # end archive
+    # the avatar image can be passed as data (content of the file) or as a file
+    # object opened in binary mode
+    project.avatar = open('path/to/file.png', 'rb')
+    project.save()
 
-.. note::
+Remove the avatar image for a project::
+
+    project.avatar = ""
+    project.save()
+
+Delete a project::
+
+    gl.projects.delete(project_id)
+    # or
+    project.delete()
+
+Restore a project marked for deletion (Premium only)::
+
+    project.restore()
+
+Fork a project::
+
+    fork = project.forks.create({})
+
+    # fork to a specific namespace
+    fork = project.forks.create({'namespace': 'myteam'})
+
+Get a list of forks for the project::
+
+    forks = project.forks.list(get_all=True)
 
-   The underscore character at the end of the methods is used to workaround a
-   conflict with a previous misuse of the ``archive`` method (deprecated but
-   not yet removed).
+Create/delete a fork relation between projects (requires admin permissions)::
 
-Repository
-----------
+    project.create_fork_relation(source_project.id)
+    project.delete_fork_relation()
 
-The following examples show how you can manipulate the project code repository.
+Get languages used in the project with percentage value::
 
-List the repository tree:
+    languages = project.languages()
 
-.. literalinclude:: projects.py
-   :start-after: # repository tree
-   :end-before: # end repository tree
+Star/unstar a project::
 
-Get the content of a file for a commit:
+    project.star()
+    project.unstar()
 
-.. literalinclude:: projects.py
-   :start-after: # repository blob
-   :end-before: # end repository blob
+Archive/unarchive a project::
 
-Get the repository archive:
+    project.archive()
+    project.unarchive()
 
-.. literalinclude:: projects.py
-   :start-after: # repository archive
-   :end-before: # end repository archive
+Start the housekeeping job::
+
+    project.housekeeping()
+
+List the repository tree::
+
+    # list the content of the root directory for the default branch
+    items = project.repository_tree()
+
+    # list the content of a subdirectory on a specific branch
+    items = project.repository_tree(path='docs', ref='branch1')
+
+Get the content and metadata of a file for a commit, using a blob sha::
+
+    items = project.repository_tree(path='docs', ref='branch1')
+    file_info = p.repository_blob(items[0]['id'])
+    content = base64.b64decode(file_info['content'])
+    size = file_info['size']
+
+Update a project submodule::
+
+    items = project.update_submodule(
+        submodule="foo/bar",
+        branch="main",
+        commit_sha="4c3674f66071e30b3311dac9b9ccc90502a72664",
+        commit_message="Message",  # optional
+    )
+
+Get the repository archive::
+
+    tgz = project.repository_archive()
+
+    # get the archive for a branch/tag/commit
+    tgz = project.repository_archive(sha='4567abc')
+
+    # get the archive in a different format
+    zip = project.repository_archive(format='zip')
+
+.. note::
+
+   For the formats available, refer to
+   https://docs.gitlab.com/api/repositories#get-file-archive
 
 .. warning::
 
    Archives are entirely stored in memory unless you use the streaming feature.
    See :ref:`the artifacts example <streaming_example>`.
 
-Get the content of a file using the blob id:
+Get the content of a file using the blob id::
 
-.. literalinclude:: projects.py
-   :start-after: # repository raw_blob
-   :end-before: # end repository raw_blob
+    # find the id for the blob (simple search)
+    id = [d['id'] for d in p.repository_tree() if d['name'] == 'README.rst'][0]
+
+    # get the content
+    file_content = p.repository_raw_blob(id)
 
 .. warning::
 
    Blobs are entirely stored in memory unless you use the streaming feature.
    See :ref:`the artifacts example <streaming_example>`.
 
-Compare two branches, tags or commits:
+Get a snapshot of the repository::
+
+    tar_file = project.snapshot()
+
+.. warning::
+
+   Snapshots are entirely stored in memory unless you use the streaming
+   feature.  See :ref:`the artifacts example <streaming_example>`.
+
+Compare two branches, tags or commits::
+
+    result = project.repository_compare('main', 'branch1')
+
+    # get the commits
+    for commit in result['commits']:
+        print(commit)
+
+    # get the diffs
+    for file_diff in result['diffs']:
+        print(file_diff)
+
+Get the merge base for two or more branches, tags or commits::
+
+    commit = project.repository_merge_base(['main', 'v1.2.3', 'bd1324e2f'])
+
+Get a list of contributors for the repository::
+
+    contributors = project.repository_contributors()
+
+Get a list of users for the repository::
+
+    users = p.users.list(get_all=True)
+
+    # search for users
+    users = p.users.list(search='pattern', get_all=True)
+
+Import / Export
+===============
+
+You can export projects from gitlab, and re-import them to create new projects
+or overwrite existing ones.
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectExport`
+  + :class:`gitlab.v4.objects.ProjectExportManager`
+  + :attr:`gitlab.v4.objects.Project.exports`
+  + :class:`gitlab.v4.objects.ProjectImport`
+  + :class:`gitlab.v4.objects.ProjectImportManager`
+  + :attr:`gitlab.v4.objects.Project.imports`
+  + :attr:`gitlab.v4.objects.ProjectManager.import_project`
+
+* GitLab API: https://docs.gitlab.com/api/project_import_export
+
+.. _project_import_export:
+
+Examples
+--------
+
+A project 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
+
+::
+
+    # Create the export
+    p = gl.projects.get(my_project)
+    export = p.exports.create()
+
+    # Wait for the 'finished' status
+    export.refresh()
+    while export.export_status != 'finished':
+        time.sleep(1)
+        export.refresh()
+
+    # Download the result
+    with open('/tmp/export.tgz', 'wb') as f:
+        export.download(streamed=True, action=f.write)
+
+You can export and upload a project to an external URL (see upstream documentation
+for more details)::
+
+    project.exports.create(
+        {
+            "upload":
+                {
+                    "url": "http://localhost:8080",
+                    "method": "POST"
+                }
+        }
+    )
+
+You can also get the status of an existing export, regardless of
+whether it was created via the API or the Web UI::
+
+    project = gl.projects.get(my_project)
+
+    # Gets the current export status
+    export = project.exports.get()
+
+Import the project into the current user's namespace::
+
+    with open('/tmp/export.tgz', 'rb') as f:
+        output = gl.projects.import_project(
+            f, path='my_new_project', name='My New Project'
+        )
+
+    # Get a ProjectImport object to track the import status
+    project_import = gl.projects.get(output['id'], lazy=True).imports.get()
+    while project_import.import_status != 'finished':
+        time.sleep(1)
+        project_import.refresh()
+
+Import the project into a namespace and override parameters::
+
+    with open('/tmp/export.tgz', 'rb') as f:
+        output = gl.projects.import_project(
+            f,
+            path='my_new_project',
+            name='My New Project',
+            namespace='my-group',
+            override_params={'visibility': 'private'},
+        )
+
+Import the project using file stored on a remote URL::
+
+    output = gl.projects.remote_import(
+        url="https://whatever.com/url/file.tar.gz",
+        path="my_new_remote_project",
+        name="My New Remote Project",
+        namespace="my-group",
+        override_params={'visibility': 'private'},
+    )
+
+Import the project using file stored on AWS S3::
+
+    output = gl.projects.remote_import_s3(
+        path="my_new_remote_project",
+        region="aws-region",
+        bucket_name="aws-bucket-name",
+        file_key="aws-file-key",
+        access_key_id="aws-access-key-id",
+        secret_access_key="secret-access-key",
+        name="My New Remote Project",
+        namespace="my-group",
+        override_params={'visibility': 'private'},
+    )
+
+Project custom attributes
+=========================
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectCustomAttribute`
+  + :class:`gitlab.v4.objects.ProjectCustomAttributeManager`
+  + :attr:`gitlab.v4.objects.Project.customattributes`
+
+* GitLab API: https://docs.gitlab.com/api/custom_attributes
+
+Examples
+--------
+
+List custom attributes for a project::
+
+    attrs = project.customattributes.list(get_all=True)
+
+Get a custom attribute for a project::
+
+    attr = project.customattributes.get(attr_key)
+
+Set (create or update) a custom attribute for a project::
+
+    attr = project.customattributes.set(attr_key, attr_value)
+
+Delete a custom attribute for a project::
+
+    attr.delete()
+    # or
+    project.customattributes.delete(attr_key)
+
+Search projects by custom attribute::
+
+    project.customattributes.set('type', 'internal')
+    gl.projects.list(custom_attributes={'type': 'internal'}, get_all=True)
+
+Project files
+=============
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectFile`
+  + :class:`gitlab.v4.objects.ProjectFileManager`
+  + :attr:`gitlab.v4.objects.Project.files`
+
+* GitLab API: https://docs.gitlab.com/api/repository_files
+
+Examples
+--------
+
+Get a file::
+
+    f = project.files.get(file_path='README.rst', ref='main')
 
-.. literalinclude:: projects.py
-   :start-after: # repository compare
-   :end-before: # end repository compare
+    # get the base64 encoded content
+    print(f.content)
 
-Get a list of contributors for the repository:
+    # get the decoded content
+    print(f.decode())
 
-.. literalinclude:: projects.py
-   :start-after: # repository contributors
-   :end-before: # end repository contributors
+Get file details from headers, without fetching its entire content::
 
-Files
------
+    headers = project.files.head('README.rst', ref='main')
 
-The following examples show how you can manipulate the project files.
+    # Get the file size:
+    # For a full list of headers returned, see upstream documentation.
+    # https://docs.gitlab.com/api/repository_files#get-file-from-repository
+    print(headers["X-Gitlab-Size"])
 
-Get a file:
+Get a raw file::
 
-.. literalinclude:: projects.py
-   :start-after: # files get
-   :end-before: # end files get
+    raw_content = project.files.raw(file_path='README.rst', ref='main')
+    print(raw_content)
+    with open('/tmp/raw-download.txt', 'wb') as f:
+        project.files.raw(file_path='README.rst', ref='main', streamed=True, action=f.write)
 
-Create a new file:
+Create a new file::
 
-.. literalinclude:: projects.py
-   :start-after: # files create
-   :end-before: # end files create
+    f = project.files.create({'file_path': 'testfile.txt',
+                              'branch': 'main',
+                              'content': file_content,
+                              'author_email': 'test@example.com',
+                              'author_name': 'yourname',
+                              'commit_message': 'Create testfile'})
 
 Update a file. The entire content must be uploaded, as plain text or as base64
-encoded text:
+encoded text::
+
+    f.content = 'new content'
+    f.save(branch='main', commit_message='Update testfile')
+
+    # or for binary data
+    # Note: decode() is required with python 3 for data serialization. You can omit
+    # it with python 2
+    f.content = base64.b64encode(open('image.png').read()).decode()
+    f.save(branch='main', commit_message='Update testfile', encoding='base64')
+
+Delete a file::
 
-.. literalinclude:: projects.py
-   :start-after: # files update
-   :end-before: # end files update
+    f.delete(commit_message='Delete testfile', branch='main')
+    # or
+    project.files.delete(file_path='testfile.txt', commit_message='Delete testfile', branch='main')
 
-Delete a file:
+Get file blame::
 
-.. literalinclude:: projects.py
-   :start-after: # files delete
-   :end-before: # end files delete
+    b = project.files.blame(file_path='README.rst', ref='main')
 
-Tags
-----
+Project tags
+============
 
-Use :class:`~gitlab.objects.ProjectTag` objects to manipulate tags. The
-:attr:`gitlab.Gitlab.project_tags` and :attr:`Project.tags
-<gitlab.objects.Project.tags>` manager objects provide helper functions.
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectTag`
+  + :class:`gitlab.v4.objects.ProjectTagManager`
+  + :attr:`gitlab.v4.objects.Project.tags`
 
-List the project tags:
+* GitLab API: https://docs.gitlab.com/api/tags
 
-.. literalinclude:: projects.py
-   :start-after: # tags list
-   :end-before: # end tags list
+Examples
+--------
 
-Get a tag:
+List the project tags::
 
-.. literalinclude:: projects.py
-   :start-after: # tags get
-   :end-before: # end tags get
+    tags = project.tags.list(get_all=True)
 
-Create a tag:
+Get a tag::
 
-.. literalinclude:: projects.py
-   :start-after: # tags create
-   :end-before: # end tags create
+    tag = project.tags.get('1.0')
 
-Set or update the release note for a tag:
+Create a tag::
 
-.. literalinclude:: projects.py
-   :start-after: # tags release
-   :end-before: # end tags release
+    tag = project.tags.create({'tag_name': '1.0', 'ref': 'main'})
 
-Delete a tag:
+Delete a tag::
 
-.. literalinclude:: projects.py
-   :start-after: # tags delete
-   :end-before: # end tags delete
+    project.tags.delete('1.0')
+    # or
+    tag.delete()
 
 .. _project_snippets:
 
-Snippets
---------
+Project snippets
+================
 
-Use :class:`~gitlab.objects.ProjectSnippet` objects to manipulate snippets. The
-:attr:`gitlab.Gitlab.project_snippets` and :attr:`Project.snippets
-<gitlab.objects.Project.snippets>` manager objects provide helper functions.
+The snippet visibility can be defined using the following constants:
 
-List the project snippets:
+* ``gitlab.const.Visibility.PRIVATE``
+* ``gitlab.const.Visibility.INTERNAL``
+* ``gitlab.const.Visibility.PUBLIC``
 
-.. literalinclude:: projects.py
-   :start-after: # snippets list
-   :end-before: # end snippets list
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectSnippet`
+  + :class:`gitlab.v4.objects.ProjectSnippetManager`
+  + :attr:`gitlab.v4.objects.Project.files`
 
-Get a snippet:
+* GitLab API: https://docs.gitlab.com/api/project_snippets
+
+Examples
+--------
 
-.. literalinclude:: projects.py
-   :start-after: # snippets get
-   :end-before: # end snippets get
+List the project snippets::
 
-Get the content of a snippet:
+    snippets = project.snippets.list(get_all=True)
 
-.. literalinclude:: projects.py
-   :start-after: # snippets content
-   :end-before: # end snippets content
+Get a snippet::
+
+    snippet = project.snippets.get(snippet_id)
+
+Get the content of a snippet::
+
+    print(snippet.content())
 
 .. warning::
 
    The snippet content is entirely stored in memory unless you use the
    streaming feature. See :ref:`the artifacts example <streaming_example>`.
 
-Create a snippet:
+Create a snippet::
+
+    snippet = project.snippets.create({'title': 'sample 1',
+                                       'files': [{
+                                            'file_path': 'foo.py',
+                                            'content': 'import gitlab'
+                                        }],
+                                       'visibility_level':
+                                       gitlab.const.Visibility.PRIVATE})
+
+Update a snippet::
 
-.. literalinclude:: projects.py
-   :start-after: # snippets create
-   :end-before: # end snippets create
+    snippet.code = 'import gitlab\nimport whatever'
+    snippet.save
 
-Update a snippet:
+Delete a snippet::
 
-.. literalinclude:: projects.py
-   :start-after: # snippets update
-   :end-before: # end snippets update
+    project.snippets.delete(snippet_id)
+    # or
+    snippet.delete()
 
-Delete a snippet:
+Get user agent detail (admin only)::
 
-.. literalinclude:: projects.py
-   :start-after: # snippets delete
-   :end-before: # end snippets delete
+    detail = snippet.user_agent_detail()
 
 Notes
------
+=====
 
-You can manipulate notes (comments) on the following resources:
+See :ref:`project-notes`.
 
-* :class:`~gitlab.objects.ProjectIssue` with
-  :class:`~gitlab.objects.ProjectIssueNote`
-* :class:`~gitlab.objects.ProjectMergeRequest` with
-  :class:`~gitlab.objects.ProjectMergeRequestNote`
-* :class:`~gitlab.objects.ProjectSnippet` with
-  :class:`~gitlab.objects.ProjectSnippetNote`
+Project members
+===============
 
-List the notes for a resource:
+Reference
+---------
+
+* v4 API:
 
-.. literalinclude:: projects.py
-   :start-after: # notes list
-   :end-before: # end notes list
+  + :class:`gitlab.v4.objects.ProjectMember`
+  + :class:`gitlab.v4.objects.ProjectMemberManager`
+  + :class:`gitlab.v4.objects.ProjectMemberAllManager`
+  + :attr:`gitlab.v4.objects.Project.members`
+  + :attr:`gitlab.v4.objects.Project.members_all`
 
-Get a note for a resource:
+* GitLab API: https://docs.gitlab.com/api/members
 
-.. literalinclude:: projects.py
-   :start-after: # notes get
-   :end-before: # end notes get
+Examples
+--------
 
-Create a note for a resource:
+List only direct project members::
 
-.. literalinclude:: projects.py
-   :start-after: # notes create
-   :end-before: # end notes create
+    members = project.members.list(get_all=True)
 
-Update a note for a resource:
+List the project members recursively (including inherited members through
+ancestor groups)::
 
-.. literalinclude:: projects.py
-   :start-after: # notes update
-   :end-before: # end notes update
+    members = project.members_all.list(get_all=True)
 
-Delete a note for a resource:
+Search project members matching a query string::
 
-.. literalinclude:: projects.py
-   :start-after: # notes delete
-   :end-before: # end notes delete
+    members = project.members.list(query='bar', get_all=True)
 
-Events
-------
+Get only direct project member::
 
-Use :class:`~gitlab.objects.ProjectEvent` objects to manipulate events. The
-:attr:`gitlab.Gitlab.project_events` and :attr:`Project.events
-<gitlab.objects.Project.events>` manager objects provide helper functions.
+    member = project.members.get(user_id)
 
-List the project events:
+Get a member of a project, including members inherited through ancestor groups::
 
-.. literalinclude:: projects.py
-   :start-after: # events list
-   :end-before: # end events list
+    members = project.members_all.get(member_id)
 
-Team members
-------------
 
-Use :class:`~gitlab.objects.ProjectMember` objects to manipulate projects
-members. The :attr:`gitlab.Gitlab.project_members` and :attr:`Project.members
-<gitlab.objects.Projects.members>` manager objects provide helper functions.
+Add a project member::
 
-List the project members:
+    member = project.members.create({'user_id': user.id, 'access_level':
+                                     gitlab.const.AccessLevel.DEVELOPER})
 
-.. literalinclude:: projects.py
-   :start-after: # members list
-   :end-before: # end members list
+Modify a project member (change the access level)::
 
-Search project members matching a query string:
+    member.access_level = gitlab.const.AccessLevel.MAINTAINER
+    member.save()
 
-.. literalinclude:: projects.py
-   :start-after: # members search
-   :end-before: # end members search
+Remove a member from the project team::
 
-Get a single project member:
+    project.members.delete(user.id)
+    # or
+    member.delete()
 
-.. literalinclude:: projects.py
-   :start-after: # members get
-   :end-before: # end members get
+Share/unshare the project with a group::
 
-Add a project member:
+    project.share(group.id, gitlab.const.AccessLevel.DEVELOPER)
+    project.unshare(group.id)
 
-.. literalinclude:: projects.py
-   :start-after: # members add
-   :end-before: # end members add
+Project hooks
+=============
 
-Modify a project member (change the access level):
+Reference
+---------
 
-.. literalinclude:: projects.py
-   :start-after: # members update
-   :end-before: # end members update
+* v4 API:
 
-Remove a member from the project team:
+  + :class:`gitlab.v4.objects.ProjectHook`
+  + :class:`gitlab.v4.objects.ProjectHookManager`
+  + :attr:`gitlab.v4.objects.Project.hooks`
 
-.. literalinclude:: projects.py
-   :start-after: # members delete
-   :end-before: # end members delete
+* GitLab API: https://docs.gitlab.com/api/projects#hooks
 
-Share the project with a group:
+Examples
+--------
 
-.. literalinclude:: projects.py
-   :start-after: # share
-   :end-before: # end share
+List the project hooks::
 
-Hooks
------
+    hooks = project.hooks.list(get_all=True)
 
-Use :class:`~gitlab.objects.ProjectHook` objects to manipulate projects
-hooks. The :attr:`gitlab.Gitlab.project_hooks` and :attr:`Project.hooks
-<gitlab.objects.Projects.hooks>` manager objects provide helper functions.
+Get a project hook::
 
-List the project hooks:
+    hook = project.hooks.get(hook_id)
 
-.. literalinclude:: projects.py
-   :start-after: # hook list
-   :end-before: # end hook list
+Create a project hook::
 
-Get a project hook:
+    hook = project.hooks.create({'url': 'http://my/action/url', 'push_events': 1})
 
-.. literalinclude:: projects.py
-   :start-after: # hook get
-   :end-before: # end hook get
+Update a project hook::
 
-Create a project hook:
+    hook.push_events = 0
+    hook.save()
 
-.. literalinclude:: projects.py
-   :start-after: # hook create
-   :end-before: # end hook create
+Test a project hook::
 
-Update a project hook:
+    hook.test("push_events")
 
-.. literalinclude:: projects.py
-   :start-after: # hook update
-   :end-before: # end hook update
+Delete a project hook::
 
-Delete a project hook:
+    project.hooks.delete(hook_id)
+    # or
+    hook.delete()
 
-.. literalinclude:: projects.py
-   :start-after: # hook delete
-   :end-before: # end hook delete
+Project Integrations
+====================
 
-Pipelines
+Reference
 ---------
 
-Use :class:`~gitlab.objects.ProjectPipeline` objects to manipulate projects
-pipelines. The :attr:`gitlab.Gitlab.project_pipelines` and
-:attr:`Project.services <gitlab.objects.Projects.pipelines>` manager objects
-provide helper functions.
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectIntegration`
+  + :class:`gitlab.v4.objects.ProjectIntegrationManager`
+  + :attr:`gitlab.v4.objects.Project.integrations`
+
+* GitLab API: https://docs.gitlab.com/api/integrations
+
+Examples
+---------
 
-List pipelines for a project:
+.. danger::
 
-.. literalinclude:: projects.py
-   :start-after: # pipeline list
-   :end-before: # end pipeline list
+    Since GitLab 13.12, ``get()`` calls to project integrations return a
+    ``404 Not Found`` response until they have been activated the first time.
 
-Get a pipeline for a project:
+    To avoid this, we recommend using `lazy=True` to prevent making
+    the initial call when activating new integrations unless they have
+    previously already been activated.
 
-.. literalinclude:: projects.py
-   :start-after: # pipeline get
-   :end-before: # end pipeline get
+Configure and enable an integration for the first time::
 
-Retry the failed builds for a pipeline:
+    integration = project.integrations.get('asana', lazy=True)
 
-.. literalinclude:: projects.py
-   :start-after: # pipeline retry
-   :end-before: # end pipeline retry
+    integration.api_key = 'randomkey'
+    integration.save()
+
+Get an existing integration::
+
+    integration = project.integrations.get('asana')
+    # display its status (enabled/disabled)
+    print(integration.active)
+
+List active project integrations::
+
+    integration = project.integrations.list(get_all=True)
+
+List the code names of available integrations (doesn't return objects)::
+
+    integrations = project.integrations.available()
+
+Disable an integration::
+
+    integration.delete()
+
+File uploads
+============
+
+Reference
+---------
 
-Cancel builds in a pipeline:
+* v4 API:
 
-.. literalinclude:: projects.py
-   :start-after: # pipeline cancel
-   :end-before: # end pipeline cancel
+  + :attr:`gitlab.v4.objects.Project.upload`
 
-Services
+* Gitlab API: https://docs.gitlab.com/api/projects#upload-a-file
+
+Examples
 --------
 
-Use :class:`~gitlab.objects.ProjectService` objects to manipulate projects
-services. The :attr:`gitlab.Gitlab.project_services` and
-:attr:`Project.services <gitlab.objects.Projects.services>` manager objects
-provide helper functions.
+Upload a file into a project using a filesystem path::
+
+    project.upload("filename.txt", filepath="/some/path/filename.txt")
+
+Upload a file into a project without a filesystem path::
+
+    project.upload("filename.txt", filedata="Raw data")
+
+Upload a file and comment on an issue using the uploaded file's
+markdown::
 
-Get a service:
+    uploaded_file = project.upload("filename.txt", filedata="data")
+    issue = project.issues.get(issue_id)
+    issue.notes.create({
+        "body": "See the attached file: {}".format(uploaded_file["markdown"])
+    })
 
-.. literalinclude:: projects.py
-   :start-after: # service get
-   :end-before: # end service get
+Upload a file and comment on an issue while using custom
+markdown to reference the uploaded file::
 
-List the code names of available services (doesn't return objects):
+    uploaded_file = project.upload("filename.txt", filedata="data")
+    issue = project.issues.get(issue_id)
+    issue.notes.create({
+        "body": "See the [attached file]({})".format(uploaded_file["url"])
+    })
 
-.. literalinclude:: projects.py
-   :start-after: # service list
-   :end-before: # end service list
+Project push rules
+==================
 
-Configure and enable a service:
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectPushRules`
+  + :class:`gitlab.v4.objects.ProjectPushRulesManager`
+  + :attr:`gitlab.v4.objects.Project.pushrules`
 
-.. literalinclude:: projects.py
-   :start-after: # service update
-   :end-before: # end service update
+* GitLab API: https://docs.gitlab.com/api/projects#push-rules
+
+Examples
+---------
 
-Disable a service:
+Create project push rules (at least one rule is necessary)::
 
-.. literalinclude:: projects.py
-   :start-after: # service delete
-   :end-before: # end service delete
+    project.pushrules.create({'deny_delete_tag': True})
 
-Boards
-------
+Get project push rules::
 
-Boards are a visual representation of existing issues for a project. Issues can
-be moved from one list to the other to track progress and help with
-priorities.
+    pr = project.pushrules.get()
 
-Get the list of existing boards for a project:
+Edit project push rules::
 
-.. literalinclude:: projects.py
-   :start-after: # boards list
-   :end-before: # end boards list
+    pr.branch_name_regex = '^(main|develop|support-\d+|release-\d+\..+|hotfix-.+|feature-.+)$'
+    pr.save()
+
+Delete project push rules::
+
+    pr.delete()
+
+Project protected tags
+======================
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectProtectedTag`
+  + :class:`gitlab.v4.objects.ProjectProtectedTagManager`
+  + :attr:`gitlab.v4.objects.Project.protectedtags`
+
+* GitLab API: https://docs.gitlab.com/api/protected_tags
+
+Examples
+---------
 
-Get a single board for a project:
+Get a list of protected tags from a project::
 
-.. literalinclude:: projects.py
-   :start-after: # boards get
-   :end-before: # end boards get
+    protected_tags = project.protectedtags.list(get_all=True)
 
-Boards have lists of issues. Each list is defined by a
-:class:`~gitlab.objects.ProjectLabel` and a position in the board.
+Get a single protected tag or wildcard protected tag::
 
-List the issue lists for a board:
+    protected_tag = project.protectedtags.get('v*')
 
-.. literalinclude:: projects.py
-   :start-after: # board lists list
-   :end-before: # end board lists list
+Protect a single repository tag or several project repository tags using a wildcard protected tag::
 
-Get a single list:
+    project.protectedtags.create({'name': 'v*', 'create_access_level': '40'})
 
-.. literalinclude:: projects.py
-   :start-after: # board lists get
-   :end-before: # end board lists get
+Unprotect the given protected tag or wildcard protected tag.::
 
-Create a new list. Note that getting the label ID is broken at the moment (see
-https://gitlab.com/gitlab-org/gitlab-ce/issues/23448):
+    protected_tag.delete()
 
-.. literalinclude:: projects.py
-   :start-after: # board lists create
-   :end-before: # end board lists create
+Additional project statistics
+=============================
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectAdditionalStatistics`
+  + :class:`gitlab.v4.objects.ProjectAdditionalStatisticsManager`
+  + :attr:`gitlab.v4.objects.Project.additionalstatistics`
+
+* GitLab API: https://docs.gitlab.com/api/project_statistics
+
+Examples
+---------
+
+Get all additional statistics of a project::
+
+    statistics = project.additionalstatistics.get()
+
+Get total fetches in last 30 days of a project::
+
+    total_fetches = project.additionalstatistics.get().fetches['total']
+
+Project storage
+=============================
+
+This endpoint requires admin access.
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectStorage`
+  + :class:`gitlab.v4.objects.ProjectStorageManager`
+  + :attr:`gitlab.v4.objects.Project.storage`
+
+* GitLab API: https://docs.gitlab.com/api/projects#get-the-path-to-repository-storage
+
+Examples
+---------
 
-Change a list position. The first list is at position 0. Moving a list will
-insert it at the given position and move the following lists up a position:
+Get the repository storage details for a project::
 
-.. literalinclude:: projects.py
-   :start-after: # board lists update
-   :end-before: # end board lists update
+    storage = project.storage.get()
 
-Delete a list:
+Get the repository storage disk path::
 
-.. literalinclude:: projects.py
-   :start-after: # board lists delete
-   :end-before: # end board lists delete
+    disk_path = project.storage.get().disk_path
diff --git a/docs/gl_objects/protected_branches.rst b/docs/gl_objects/protected_branches.rst
new file mode 100644
index 000000000..a1b1ef5c5
--- /dev/null
+++ b/docs/gl_objects/protected_branches.rst
@@ -0,0 +1,61 @@
+##################
+Protected branches
+##################
+
+You can define a list of protected branch names on a repository or group.
+Names can use wildcards (``*``).
+
+References
+----------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectProtectedBranch`
+  + :class:`gitlab.v4.objects.ProjectProtectedBranchManager`
+  + :attr:`gitlab.v4.objects.Project.protectedbranches`
+  + :class:`gitlab.v4.objects.GroupProtectedBranch`
+  + :class:`gitlab.v4.objects.GroupProtectedBranchManager`
+  + :attr:`gitlab.v4.objects.Group.protectedbranches`
+
+* GitLab API: https://docs.gitlab.com/api/protected_branches#protected-branches-api
+
+Examples
+--------
+
+Get the list of protected branches for a project or group::
+
+    p_branches = project.protectedbranches.list()
+    p_branches = group.protectedbranches.list()
+
+Get a single protected branch::
+
+    p_branch = project.protectedbranches.get('main')
+    p_branch = group.protectedbranches.get('main')
+
+Update a protected branch::
+
+    p_branch.allow_force_push = True
+    p_branch.save()
+
+Create a protected branch::
+
+    p_branch = project.protectedbranches.create({
+        'name': '*-stable',
+        'merge_access_level': gitlab.const.AccessLevel.DEVELOPER,
+        'push_access_level': gitlab.const.AccessLevel.MAINTAINER
+    })
+
+Create a protected branch with more granular access control::
+
+    p_branch = project.protectedbranches.create({
+        'name': '*-stable',
+        'allowed_to_push': [{"user_id": 99}, {"user_id": 98}],
+        'allowed_to_merge': [{"group_id": 653}],
+        'allowed_to_unprotect': [{"access_level": gitlab.const.AccessLevel.MAINTAINER}]
+    })
+
+Delete a protected branch::
+
+    project.protectedbranches.delete('*-stable')
+    # or
+    p_branch.delete()
diff --git a/docs/gl_objects/protected_container_repositories.rst b/docs/gl_objects/protected_container_repositories.rst
new file mode 100644
index 000000000..bc37c6138
--- /dev/null
+++ b/docs/gl_objects/protected_container_repositories.rst
@@ -0,0 +1,44 @@
+################################
+Protected container repositories
+################################
+
+You can list and manage container registry protection rules in a project.
+
+References
+----------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectRegistryRepositoryProtectionRuleRule`
+  + :class:`gitlab.v4.objects.ProjectRegistryRepositoryProtectionRuleRuleManager`
+  + :attr:`gitlab.v4.objects.Project.registry_protection_repository_rules`
+
+* GitLab API: https://docs.gitlab.com/api/container_repository_protection_rules
+
+Examples
+--------
+
+List the container registry protection rules for a project::
+
+    registry_rules = project.registry_protection_repository_rules.list(get_all=True)
+
+Create a container registry protection rule::
+
+    registry_rule = project.registry_protection_repository_rules.create(
+        {
+            'repository_path_pattern': 'test/image',
+            'minimum_access_level_for_push': 'maintainer',
+            'minimum_access_level_for_delete': 'maintainer',
+        }
+    )
+
+Update a container registry protection rule::
+
+    registry_rule.minimum_access_level_for_push = 'owner'
+    registry_rule.save()
+
+Delete a container registry protection rule::
+
+    registry_rule = project.registry_protection_repository_rules.delete(registry_rule.id)
+    # or
+    registry_rule.delete()
diff --git a/docs/gl_objects/protected_environments.rst b/docs/gl_objects/protected_environments.rst
new file mode 100644
index 000000000..e36c1fad0
--- /dev/null
+++ b/docs/gl_objects/protected_environments.rst
@@ -0,0 +1,45 @@
+######################
+Protected environments
+######################
+
+You can list and manage protected environments in a project.
+
+References
+----------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectProtectedEnvironment`
+  + :class:`gitlab.v4.objects.ProjectProtectedEnvironmentManager`
+  + :attr:`gitlab.v4.objects.Project.protected_environment`
+
+* GitLab API: https://docs.gitlab.com/api/protected_environments
+
+Examples
+--------
+
+Get the list of protected environments for a project::
+
+    p_environments = project.protected_environments.list(get_all=True)
+
+Get a single protected environment::
+
+    p_environments = project.protected_environments.get('production')
+
+Protect an existing environment::
+
+    p_environment = project.protected_environments.create(
+        {
+            'name': 'production',
+            'deploy_access_levels': [
+                {'access_level': 40}
+            ],
+        }
+    )
+
+
+Unprotect a protected environment::
+
+    p_environment = project.protected_environments.delete('production')
+    # or
+    p_environment.delete()
diff --git a/docs/gl_objects/protected_packages.rst b/docs/gl_objects/protected_packages.rst
new file mode 100644
index 000000000..6865b6992
--- /dev/null
+++ b/docs/gl_objects/protected_packages.rst
@@ -0,0 +1,44 @@
+##################
+Protected packages
+##################
+
+You can list and manage package protection rules in a project.
+
+References
+----------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectPackageProtectionRule`
+  + :class:`gitlab.v4.objects.ProjectPackageProtectionRuleManager`
+  + :attr:`gitlab.v4.objects.Project.package_protection_rules`
+
+* GitLab API: https://docs.gitlab.com/api/project_packages_protection_rules
+
+Examples
+--------
+
+List the package protection rules for a project::
+
+    package_rules = project.package_protection_rules.list(get_all=True)
+
+Create a package protection rule::
+
+    package_rule = project.package_protection_rules.create(
+        {
+            'package_name_pattern': 'v*',
+            'package_type': 'npm',
+            'minimum_access_level_for_push': 'maintainer'
+        }
+    )
+
+Update a package protection rule::
+
+    package_rule.minimum_access_level_for_push = 'developer'
+    package_rule.save()
+
+Delete a package protection rule::
+
+    package_rule = project.package_protection_rules.delete(package_rule.id)
+    # or
+    package_rule.delete()
diff --git a/docs/gl_objects/pull_mirror.rst b/docs/gl_objects/pull_mirror.rst
new file mode 100644
index 000000000..bc83ba36d
--- /dev/null
+++ b/docs/gl_objects/pull_mirror.rst
@@ -0,0 +1,38 @@
+######################
+Project Pull Mirror
+######################
+
+Pull Mirror allow you to set up pull mirroring for a project.
+
+References
+==========
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectPullMirror`
+  + :class:`gitlab.v4.objects.ProjectPullMirrorManager`
+  + :attr:`gitlab.v4.objects.Project.pull_mirror`
+
+* GitLab API: https://docs.gitlab.com/api/project_pull_mirroring/
+
+Examples
+--------
+
+Get the current pull mirror of a project::
+
+    mirrors = project.pull_mirror.get()
+
+Create (and enable) a remote mirror for a project::
+
+    mirror = project.pull_mirror.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()
+
+Start an sync of the pull mirror::
+
+  mirror.start()
diff --git a/docs/gl_objects/releases.rst b/docs/gl_objects/releases.rst
new file mode 100644
index 000000000..99be7ce9f
--- /dev/null
+++ b/docs/gl_objects/releases.rst
@@ -0,0 +1,92 @@
+########
+Releases
+########
+
+Project releases
+================
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectRelease`
+  + :class:`gitlab.v4.objects.ProjectReleaseManager`
+  + :attr:`gitlab.v4.objects.Project.releases`
+
+* Gitlab API: https://docs.gitlab.com/api/releases/index
+
+Examples
+--------
+
+Get a list of releases from a project::
+
+    project = gl.projects.get(project_id, lazy=True)
+    release = project.releases.list(get_all=True)
+
+Get a single release::
+
+    release = project.releases.get('v1.2.3')
+
+Edit a release::
+
+    release.name = "Demo Release"
+    release.description = "release notes go here"
+    release.save()
+
+Create a release for a project tag::
+
+    release = project.releases.create({'name':'Demo Release', 'tag_name':'v1.2.3', 'description':'release notes go here'})
+
+Delete a release::
+
+    # via its tag name from project attributes
+    release = project.releases.delete('v1.2.3')
+
+    # delete object directly
+    release.delete()
+
+.. note::
+
+    The Releases API is one of the few working with ``CI_JOB_TOKEN``, but the project can't
+    be fetched with the token. Thus use `lazy` for the project as in the above example.
+
+    Also be aware that most of the capabilities of the endpoint were not accessible with
+    ``CI_JOB_TOKEN`` until Gitlab version 14.5.
+
+Project release links
+=====================
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectReleaseLink`
+  + :class:`gitlab.v4.objects.ProjectReleaseLinkManager`
+  + :attr:`gitlab.v4.objects.ProjectRelease.links`
+
+* Gitlab API: https://docs.gitlab.com/api/releases/links
+
+Examples
+--------
+
+Get a list of releases from a project::
+
+    links = release.links.list()
+
+Get a single release link::
+
+    link = release.links.get(1)
+
+Create a release link for a release::
+
+    link = release.links.create({"url": "https://example.com/asset", "name": "asset"})
+
+Delete a release link::
+
+    # via its ID from release attributes
+    release.links.delete(1)
+
+    # delete object directly
+    link.delete()
diff --git a/docs/gl_objects/remote_mirrors.rst b/docs/gl_objects/remote_mirrors.rst
new file mode 100644
index 000000000..b4610117d
--- /dev/null
+++ b/docs/gl_objects/remote_mirrors.rst
@@ -0,0 +1,38 @@
+######################
+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/api/remote_mirrors
+
+Examples
+--------
+
+Get the list of a project's remote mirrors::
+
+    mirrors = project.remote_mirrors.list(get_all=True)
+
+Create (and enable) a remote mirror for a project::
+
+    mirror = project.remote_mirrors.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()
+
+Delete an existing remote mirror::
+
+  mirror.delete()
diff --git a/docs/gl_objects/repositories.rst b/docs/gl_objects/repositories.rst
new file mode 100644
index 000000000..b0c049bd2
--- /dev/null
+++ b/docs/gl_objects/repositories.rst
@@ -0,0 +1,32 @@
+#####################
+Registry Repositories
+#####################
+
+References
+----------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectRegistryRepository`
+  + :class:`gitlab.v4.objects.ProjectRegistryRepositoryManager`
+  + :attr:`gitlab.v4.objects.Project.repositories`
+
+* Gitlab API: https://docs.gitlab.com/api/container_registry
+
+Examples
+--------
+
+Get the list of container registry repositories associated with the project::
+
+      repositories = project.repositories.list(get_all=True)
+
+Get the list of all project container registry repositories in a group::
+
+      repositories = group.registry_repositories.list()
+
+Delete repository::
+
+      project.repositories.delete(id=x)
+      # or 
+      repository = repositories.pop()
+      repository.delete()
diff --git a/docs/gl_objects/repository_tags.rst b/docs/gl_objects/repository_tags.rst
new file mode 100644
index 000000000..a8e4be33f
--- /dev/null
+++ b/docs/gl_objects/repository_tags.rst
@@ -0,0 +1,47 @@
+########################
+Registry Repository Tags
+########################
+
+References
+----------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectRegistryTag`
+  + :class:`gitlab.v4.objects.ProjectRegistryTagManager`
+  + :attr:`gitlab.v4.objects.Repository.tags`
+
+* Gitlab API: https://docs.gitlab.com/api/container_registry
+
+Examples
+--------
+
+Get the list of repository tags in given registry::
+
+      repositories = project.repositories.list(get_all=True)
+      repository = repositories.pop()
+      tags = repository.tags.list(get_all=True)
+
+Get specific tag::
+      
+      repository.tags.get(id=tag_name)
+
+Delete tag::
+
+      repository.tags.delete(id=tag_name)
+      # or
+      tag = repository.tags.get(id=tag_name)
+      tag.delete()
+
+Delete tag in bulk::
+
+      repository.tags.delete_in_bulk(keep_n=1)
+      # or 
+      repository.tags.delete_in_bulk(older_than="1m")
+      # or 
+      repository.tags.delete_in_bulk(name_regex="v.+", keep_n=2)
+
+.. note::   
+
+      Delete in bulk is asynchronous operation and may take a while. 
+      Refer to: https://docs.gitlab.com/api/container_registry#delete-repository-tags-in-bulk 
diff --git a/docs/gl_objects/resource_groups.rst b/docs/gl_objects/resource_groups.rst
new file mode 100644
index 000000000..4b1a9693f
--- /dev/null
+++ b/docs/gl_objects/resource_groups.rst
@@ -0,0 +1,38 @@
+###############
+Resource Groups
+###############
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectResourceGroup`
+  + :class:`gitlab.v4.objects.ProjectResourceGroupManager`
+  + :attr:`gitlab.v4.objects.Project.resource_groups`
+  + :class:`gitlab.v4.objects.ProjectResourceGroupUpcomingJob`
+  + :class:`gitlab.v4.objects.ProjectResourceGroupUpcomingJobManager`
+  + :attr:`gitlab.v4.objects.ProjectResourceGroup.upcoming_jobs`
+
+* Gitlab API: https://docs.gitlab.com/api/resource_groups
+
+Examples
+--------
+
+List resource groups for a project::
+
+    project = gl.projects.get(project_id, lazy=True)
+    resource_group = project.resource_groups.list(get_all=True)
+
+Get a single resource group::
+
+    resource_group = project.resource_groups.get("production")
+
+Edit a resource group::
+
+    resource_group.process_mode = "oldest_first"
+    resource_group.save()
+
+List upcoming jobs for a resource group::
+
+    upcoming_jobs = resource_group.upcoming_jobs.list(get_all=True)
diff --git a/docs/gl_objects/runners.py b/docs/gl_objects/runners.py
deleted file mode 100644
index 5092dc08f..000000000
--- a/docs/gl_objects/runners.py
+++ /dev/null
@@ -1,22 +0,0 @@
-# list
-# List owned runners
-runners = gl.runners.list()
-# List all runners, using a filter
-runners = gl.runners.all(scope='paused')
-# end list
-
-# get
-runner = gl.runners.get(runner_id)
-# end get
-
-# update
-runner = gl.runners.get(runner_id)
-runner.tag_list.append('new_tag')
-runner.save()
-# end update
-
-# delete
-gl.runners.delete(runner_id)
-# or
-runner.delete()
-# end delete
diff --git a/docs/gl_objects/runners.rst b/docs/gl_objects/runners.rst
index 32d671999..4d0686a4c 100644
--- a/docs/gl_objects/runners.rst
+++ b/docs/gl_objects/runners.rst
@@ -2,44 +2,162 @@
 Runners
 #######
 
-Global runners
-==============
+Runners are external processes used to run CI jobs. They are deployed by the
+administrator and registered to the GitLab instance.
 
-Use :class:`~gitlab.objects.Runner` objects to manipulate runners. The
-:attr:`gitlab.Gitlab.runners` manager object provides helper functions.
+Shared runners are available for all projects. Specific runners are enabled for
+a list of projects.
+
+Global runners (admin)
+======================
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.Runner`
+  + :class:`gitlab.v4.objects.RunnerManager`
+  + :attr:`gitlab.Gitlab.runners`
+  + :class:`gitlab.v4.objects.RunnerAll`
+  + :class:`gitlab.v4.objects.RunnerAllManager`
+  + :attr:`gitlab.Gitlab.runners_all`
+
+* GitLab API: https://docs.gitlab.com/api/runners
 
 Examples
 --------
 
-Use the ``list()`` and ``all()`` methods to list runners.
+Use the ``runners.list()`` and ``runners_all.list()`` methods to list runners.
+``runners.list()`` - Get a list of specific runners available to the user
+``runners_all.list()``  - Get a list of all runners in the GitLab instance
+(specific and shared). Access is restricted to users with administrator access.
+
 
-The ``all()`` method accepts a ``scope`` parameter to filter the list. Allowed
-values for this parameter are ``specific``, ``shared``, ``active``, ``paused``
-and ``online``.
+Both methods accept a ``scope`` parameter to filter the list. Allowed values
+for this parameter are:
+
+* ``active``
+* ``paused``
+* ``online``
+* ``specific`` (``runners_all.list()`` only)
+* ``shared`` (``runners_all.list()`` only)
 
 .. note::
 
    The returned objects hold minimal information about the runners. Use the
    ``get()`` method to retrieve detail about a runner.
 
-.. literalinclude:: runners.py
-   :start-after: # list
-   :end-before: # end list
+   Runners returned via ``runners_all.list()`` also cannot be manipulated
+   directly. You will need to use the ``get()`` method to create an editable
+   object.
+
+::
+
+    # List owned runners
+    runners = gl.runners.list(get_all=True)
+
+    # List owned runners with a filter
+    runners = gl.runners.list(scope='active', get_all=True)
+
+    # List all runners in the GitLab instance (specific and shared), using a filter
+    runners = gl.runners_all.list(scope='paused', get_all=True)
+
+Get a runner's detail::
+
+    runner = gl.runners.get(runner_id)
+
+Register a new runner::
+
+    runner = gl.runners.create({'token': secret_token})
+
+.. note::
+
+   A new runner registration workflow has been introduced since GitLab 16.0. This new
+   workflow comes with a new API endpoint to create runners, which does not use
+   registration tokens.
+
+   The new endpoint can be called using ``gl.user.runners.create()`` after
+   authenticating with ``gl.auth()``.
+
+Update a runner::
+
+    runner = gl.runners.get(runner_id)
+    runner.tag_list.append('new_tag')
+    runner.save()
+
+Remove a runner::
+
+    gl.runners.delete(runner_id)
+    # or
+    runner.delete()
+
+Remove a runner by its authentication token::
 
-Get a runner's detail:
+    gl.runners.delete(token="runner-auth-token")
 
-.. literalinclude:: runners.py
-   :start-after: # get
-   :end-before: # end get
+Verify a registered runner token::
+
+    try:
+        gl.runners.verify(runner_token)
+        print("Valid token")
+    except GitlabVerifyError:
+        print("Invalid token")
+
+Project/Group runners
+=====================
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectRunner`
+  + :class:`gitlab.v4.objects.ProjectRunnerManager`
+  + :attr:`gitlab.v4.objects.Project.runners`
+  + :class:`gitlab.v4.objects.GroupRunner`
+  + :class:`gitlab.v4.objects.GroupRunnerManager`
+  + :attr:`gitlab.v4.objects.Group.runners`
+
+* GitLab API: https://docs.gitlab.com/api/runners
+
+Examples
+--------
+
+List the runners for a project::
+
+    runners = project.runners.list(get_all=True)
+
+Enable a specific runner for a project::
+
+    p_runner = project.runners.create({'runner_id': runner.id})
+
+Disable a specific runner for a project::
+
+    project.runners.delete(runner.id)
+
+Runner jobs
+===========
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.RunnerJob`
+  + :class:`gitlab.v4.objects.RunnerJobManager`
+  + :attr:`gitlab.v4.objects.Runner.jobs`
+
+* GitLab API: https://docs.gitlab.com/api/runners
+
+Examples
+--------
 
-Update a runner:
+List for jobs for a runner::
 
-.. literalinclude:: runners.py
-   :start-after: # update
-   :end-before: # end update
+    jobs = runner.jobs.list(get_all=True)
 
-Remove a runner:
+Filter the list using the jobs status::
 
-.. literalinclude:: runners.py
-   :start-after: # delete
-   :end-before: # end delete
+    # status can be 'running', 'success', 'failed' or 'canceled'
+    active_jobs = runner.jobs.list(status='running', get_all=True)
diff --git a/docs/gl_objects/search.rst b/docs/gl_objects/search.rst
new file mode 100644
index 000000000..78ec83785
--- /dev/null
+++ b/docs/gl_objects/search.rst
@@ -0,0 +1,77 @@
+##########
+Search API
+##########
+
+You can search for resources at the top level, in a project or in a group.
+Searches are based on a scope (issues, merge requests, and so on) and a search
+string. The following constants are provided to represent the possible scopes:
+
+
+* Shared scopes (global, group and project):
+
+  + ``gitlab.const.SearchScope.PROJECTS``: ``projects``
+  + ``gitlab.const.SearchScope.ISSUES``: ``issues``
+  + ``gitlab.const.SearchScope.MERGE_REQUESTS``: ``merge_requests``
+  + ``gitlab.const.SearchScope.MILESTONES``: ``milestones``
+  + ``gitlab.const.SearchScope.WIKI_BLOBS``: ``wiki_blobs``
+  + ``gitlab.const.SearchScope.COMMITS``: ``commits``
+  + ``gitlab.const.SearchScope.BLOBS``: ``blobs``
+  + ``gitlab.const.SearchScope.USERS``: ``users``
+
+
+* specific global scope:
+
+  + ``gitlab.const.SearchScope.GLOBAL_SNIPPET_TITLES``: ``snippet_titles``
+
+
+* specific project scope:
+
+  + ``gitlab.const.SearchScope.PROJECT_NOTES``: ``notes``
+
+
+Reference
+---------
+
+* v4 API:
+
+  + :attr:`gitlab.Gitlab.search`
+  + :attr:`gitlab.v4.objects.Group.search`
+  + :attr:`gitlab.v4.objects.Project.search`
+
+* GitLab API: https://docs.gitlab.com/api/search
+
+Examples
+--------
+
+Search for issues matching a specific string::
+
+    # global search
+    gl.search(gitlab.const.SearchScope.ISSUES, 'regression')
+
+    # group search
+    group = gl.groups.get('mygroup')
+    group.search(gitlab.const.SearchScope.ISSUES, 'regression')
+
+    # project search
+    project = gl.projects.get('myproject')
+    project.search(gitlab.const.SearchScope.ISSUES, 'regression')
+
+The ``search()`` methods implement the pagination support::
+
+    # get lists of 10 items, and start at page 2
+    gl.search(gitlab.const.SearchScope.ISSUES, search_str, page=2, per_page=10)
+
+    # get a generator that will automatically make required API calls for
+    # pagination
+    for item in gl.search(gitlab.const.SearchScope.ISSUES, search_str, iterator=True):
+        do_something(item)
+
+The search API doesn't return objects, but dicts. If you need to act on
+objects, you need to create them explicitly::
+
+    for item in gl.search(gitlab.const.SearchScope.ISSUES, search_str, iterator=True):
+        issue_project = gl.projects.get(item['project_id'], lazy=True)
+        issue = issue_project.issues.get(item['iid'])
+        issue.state = 'closed'
+        issue.save()
+
diff --git a/docs/gl_objects/secure_files.rst b/docs/gl_objects/secure_files.rst
new file mode 100644
index 000000000..62d6c4b12
--- /dev/null
+++ b/docs/gl_objects/secure_files.rst
@@ -0,0 +1,47 @@
+############
+Secure Files
+############
+
+secure files
+============
+
+References
+----------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectSecureFile`
+  + :class:`gitlab.v4.objects.ProjectSecureFileManager`
+  + :attr:`gitlab.v4.objects.Project.secure_files`
+
+* GitLab API: https://docs.gitlab.com/api/secure_files
+
+Examples
+--------
+
+Get a project secure file::
+
+    secure_files = gl.projects.get(1, lazy=True).secure_files.get(1)
+    print(secure_files.name)
+
+List project secure files::
+
+    secure_files = gl.projects.get(1, lazy=True).secure_files.list(get_all=True)
+    print(secure_files[0].name)
+
+Create project secure file::
+
+    secure_file = gl.projects.get(1).secure_files.create({"name": "test", "file": "secure.txt"})
+
+Download a project secure file::
+
+    content = secure_file.download()
+    print(content)
+    with open("/tmp/secure.txt", "wb") as f:
+        secure_file.download(streamed=True, action=f.write)
+
+Remove a project secure file::
+
+    gl.projects.get(1).secure_files.delete(1)
+    # or
+    secure_file.delete()
diff --git a/docs/gl_objects/settings.py b/docs/gl_objects/settings.py
deleted file mode 100644
index 834d43d3a..000000000
--- a/docs/gl_objects/settings.py
+++ /dev/null
@@ -1,8 +0,0 @@
-# get
-settings = gl.settings.get()
-# end get
-
-# update
-s.signin_enabled = False
-s.save()
-# end update
diff --git a/docs/gl_objects/settings.rst b/docs/gl_objects/settings.rst
index 26f68c598..a0ab7f012 100644
--- a/docs/gl_objects/settings.rst
+++ b/docs/gl_objects/settings.rst
@@ -2,21 +2,25 @@
 Settings
 ########
 
-Use :class:`~gitlab.objects.ApplicationSettings` objects to manipulate Gitlab
-settings. The :attr:`gitlab.Gitlab.settings` manager object provides helper
-functions.
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ApplicationSettings`
+  + :class:`gitlab.v4.objects.ApplicationSettingsManager`
+  + :attr:`gitlab.Gitlab.settings`
+
+* GitLab API: https://docs.gitlab.com/api/settings
 
 Examples
 --------
 
-Get the settings:
+Get the settings::
 
-.. literalinclude:: settings.py
-   :start-after: # get
-   :end-before: # end get
+    settings = gl.settings.get()
 
-Update the settings:
+Update the settings::
 
-.. literalinclude:: settings.py
-   :start-after: # update
-   :end-before: # end update
+    settings.signin_enabled = False
+    settings.save()
diff --git a/docs/gl_objects/sidekiq.rst b/docs/gl_objects/sidekiq.rst
index a75a02d51..870de8745 100644
--- a/docs/gl_objects/sidekiq.rst
+++ b/docs/gl_objects/sidekiq.rst
@@ -2,8 +2,15 @@
 Sidekiq metrics
 ###############
 
-Use the :attr:`gitlab.Gitlab.sideqik` manager object to access Gitlab Sidekiq
-server metrics.
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.SidekiqManager`
+  + :attr:`gitlab.Gitlab.sidekiq`
+
+* GitLab API: https://docs.gitlab.com/api/sidekiq_metrics
 
 Examples
 --------
diff --git a/docs/gl_objects/snippets.py b/docs/gl_objects/snippets.py
deleted file mode 100644
index 091aef60e..000000000
--- a/docs/gl_objects/snippets.py
+++ /dev/null
@@ -1,30 +0,0 @@
-# list
-snippets = gl.snippets.list()
-# end list
-
-# public list
-public_snippets = gl.snippets.public()
-# nd public list
-
-# get
-snippet = gl.snippets.get(snippet_id)
-# get the content
-content = snippet.raw()
-# end get
-
-# create
-snippet = gl.snippets.create({'title': 'snippet1',
-                              'file_name': 'snippet1.py',
-                              'content': open('snippet1.py').read()})
-# end create
-
-# update
-snippet.visibility_level = gitlab.Project.VISIBILITY_PUBLIC
-snippet.save()
-# end update
-
-# delete
-gl.snippets.delete(snippet_id)
-# or
-snippet.delete()
-# end delete
diff --git a/docs/gl_objects/snippets.rst b/docs/gl_objects/snippets.rst
index 34c39fba8..3633ec142 100644
--- a/docs/gl_objects/snippets.rst
+++ b/docs/gl_objects/snippets.rst
@@ -2,32 +2,42 @@
 Snippets
 ########
 
-You can store code snippets in Gitlab. Snippets can be attached to projects
-(see :ref:`project_snippets`), but can also be detached.
+Reference
+=========
 
-* Object class: :class:`gitlab.objects.Namespace`
-* Manager object: :attr:`gitlab.Gitlab.snippets`
+* v4 API:
+
+  + :class:`gitlab.v4.objects.Snippet`
+  + :class:`gitlab.v4.objects.SnipptManager`
+  + :attr:`gitlab.Gitlab.snippets`
+
+* GitLab API: https://docs.gitlab.com/api/snippets
 
 Examples
 ========
 
-List snippets woned by the current user:
+List snippets owned by the current user::
 
-.. literalinclude:: snippets.py
-   :start-after: # list
-   :end-before: # end list
+    snippets = gl.snippets.list(get_all=True)
 
-List the public snippets:
+List the public snippets::
 
-.. literalinclude:: snippets.py
-   :start-after: # public list
-   :end-before: # end public list
+    public_snippets = gl.snippets.list_public()
 
-Get a snippet:
+List all snippets::
 
-.. literalinclude:: snippets.py
-   :start-after: # get
-   :end-before: # end get
+    all_snippets = gl.snippets.list_all(get_all=True)
+
+.. warning::
+
+   Only users with the Administrator or Auditor access levels can see all snippets
+   (both personal and project). See the upstream API documentation for more details.
+
+Get a snippet::
+
+    snippet = gl.snippets.get(snippet_id)
+    # get the content
+    content = snippet.content()
 
 .. warning::
 
@@ -35,20 +45,34 @@ Get a snippet:
    See :ref:`the artifacts example <streaming_example>`.
 
 
-Create a snippet:
+Create a snippet::
+
+    snippet = gl.snippets.create({'title': 'snippet1',
+                                  'files': [{
+                                      'file_path': 'foo.py',
+                                      'content': 'import gitlab'
+                                   }],
+                                })
+
+Update the snippet attributes::
+
+    snippet.visibility_level = gitlab.const.Visibility.PUBLIC
+    snippet.save()
+
+To update a snippet code you need to create a ``ProjectSnippet`` object::
 
-.. literalinclude:: snippets.py
-   :start-after: # create
-   :end-before: # end create
+    snippet = gl.snippets.get(snippet_id)
+    project = gl.projects.get(snippet.projec_id, lazy=True)
+    editable_snippet = project.snippets.get(snippet.id)
+    editable_snippet.code = new_snippet_content
+    editable_snippet.save()
 
-Update a snippet:
+Delete a snippet::
 
-.. literalinclude:: snippets.py
-   :start-after: # update
-   :end-before: # end update
+    gl.snippets.delete(snippet_id)
+    # or
+    snippet.delete()
 
-Delete a snippet:
+Get user agent detail (admin only)::
 
-.. literalinclude:: snippets.py
-   :start-after: # delete
-   :end-before: # end delete
+    detail = snippet.user_agent_detail()
diff --git a/docs/gl_objects/statistics.rst b/docs/gl_objects/statistics.rst
new file mode 100644
index 000000000..fd49372bb
--- /dev/null
+++ b/docs/gl_objects/statistics.rst
@@ -0,0 +1,21 @@
+##########
+Statistics
+##########
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ApplicationStatistics`
+  + :class:`gitlab.v4.objects.ApplicationStatisticsManager`
+  + :attr:`gitlab.Gitlab.statistics`
+
+* GitLab API: https://docs.gitlab.com/api/statistics
+
+Examples
+--------
+
+Get the statistics::
+
+    statistics = gl.statistics.get()
diff --git a/docs/gl_objects/status_checks.rst b/docs/gl_objects/status_checks.rst
new file mode 100644
index 000000000..062231216
--- /dev/null
+++ b/docs/gl_objects/status_checks.rst
@@ -0,0 +1,57 @@
+#######################
+External Status Checks
+#######################
+
+Manage external status checks for projects and merge requests.
+
+
+Project external status checks
+===============================
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectExternalStatusCheck`
+  + :class:`gitlab.v4.objects.ProjectExternalStatusCheckManager`
+  + :attr:`gitlab.v4.objects.Project.external_status_checks`
+
+* GitLab API: https://docs.gitlab.com/api/status_checks
+
+Examples
+---------
+
+List external status checks for a project::
+
+    status_checks = project.external_status_checks.list(get_all=True)
+
+Create an external status check with shared secret::
+
+    status_checks = project.external_status_checks.create({
+        "name": "mr_blocker",
+        "external_url": "https://example.com/mr-status-check",
+        "shared_secret": "secret-string"
+    })
+
+Create an external status check with shared secret for protected branches::
+
+    protected_branch = project.protectedbranches.get('main')
+
+    status_check = project.external_status_checks.create({
+        "name": "mr_blocker",
+        "external_url": "https://example.com/mr-status-check",
+        "shared_secret": "secret-string",
+        "protected_branch_ids": [protected_branch.id]
+    })
+
+
+Update an external status check::
+
+    status_check.external_url = "https://example.com/mr-blocker"
+    status_check.save()
+
+Delete an external status check::
+
+    status_check.delete(status_check_id)
+
diff --git a/docs/gl_objects/system_hooks.py b/docs/gl_objects/system_hooks.py
deleted file mode 100644
index 9bc487bcb..000000000
--- a/docs/gl_objects/system_hooks.py
+++ /dev/null
@@ -1,17 +0,0 @@
-# list
-hooks = gl.hooks.list()
-# end list
-
-# test
-gl.hooks.get(hook_id)
-# end test
-
-# create
-hook = gl.hooks.create({'url': 'http://your.target.url'})
-# end create
-
-# delete
-gl.hooks.delete(hook_id)
-# or
-hook.delete()
-# end delete
diff --git a/docs/gl_objects/system_hooks.rst b/docs/gl_objects/system_hooks.rst
index 1d1804bb4..7acba56a3 100644
--- a/docs/gl_objects/system_hooks.rst
+++ b/docs/gl_objects/system_hooks.rst
@@ -2,32 +2,34 @@
 System hooks
 ############
 
-Use :class:`~gitlab.objects.Hook` objects to manipulate system hooks. The
-:attr:`gitlab.Gitlab.hooks` manager object provides helper functions.
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.Hook`
+  + :class:`gitlab.v4.objects.HookManager`
+  + :attr:`gitlab.Gitlab.hooks`
+
+* GitLab API: https://docs.gitlab.com/api/system_hooks
 
 Examples
 --------
 
-List the system hooks:
+List the system hooks::
 
-.. literalinclude:: system_hooks.py
-   :start-after: # list
-   :end-before: # end list
+    hooks = gl.hooks.list(get_all=True)
 
-Create a system hook:
+Create a system hook::
 
-.. literalinclude:: system_hooks.py
-   :start-after: # create
-   :end-before: # end create
+    gl.hooks.get(hook_id)
 
-Test a system hook. The returned object is not usable (it misses the hook ID):
+Test a system hook. The returned object is not usable (it misses the hook ID)::
 
-.. literalinclude:: system_hooks.py
-   :start-after: # test
-   :end-before: # end test
+    hook = gl.hooks.create({'url': 'http://your.target.url'})
 
-Delete a system hook:
+Delete a system hook::
 
-.. literalinclude:: system_hooks.py
-   :start-after: # delete
-   :end-before: # end delete
+    gl.hooks.delete(hook_id)
+    # or
+    hook.delete()
diff --git a/docs/gl_objects/templates.py b/docs/gl_objects/templates.py
deleted file mode 100644
index 1bc97bb8f..000000000
--- a/docs/gl_objects/templates.py
+++ /dev/null
@@ -1,26 +0,0 @@
-# license list
-licenses = gl.licenses.list()
-# end license list
-
-# license get
-license = gl.licenses.get('apache-2.0', project='foobar', fullname='John Doe')
-print(license.content)
-# end license get
-
-# gitignore list
-gitignores = gl.gitignores.list()
-# end gitignore list
-
-# gitignore get
-gitignore = gl.gitignores.get('Python')
-print(gitignore.content)
-# end gitignore get
-
-# gitlabciyml list
-gitlabciymls = gl.gitlabciymls.list()
-# end gitlabciyml list
-
-# gitlabciyml get
-gitlabciyml = gl.gitlabciymls.get('Pelican')
-print(gitlabciyml.content)
-# end gitlabciyml get
diff --git a/docs/gl_objects/templates.rst b/docs/gl_objects/templates.rst
index 1ce429d3c..6a03a7d1a 100644
--- a/docs/gl_objects/templates.rst
+++ b/docs/gl_objects/templates.rst
@@ -7,66 +7,178 @@ You can request templates for different type of files:
 * License files
 * .gitignore files
 * GitLab CI configuration files
+* Dockerfiles
 
 License templates
 =================
 
-* Object class: :class:`~gitlab.objects.License`
-* Manager object: :attr:`gitlab.Gitlab.licenses`
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.License`
+  + :class:`gitlab.v4.objects.LicenseManager`
+  + :attr:`gitlab.Gitlab.licenses`
+
+* GitLab API: https://docs.gitlab.com/api/templates/licenses
 
 Examples
 --------
 
-List known license templates:
+List known license templates::
 
-.. literalinclude:: templates.py
-   :start-after: # license list
-   :end-before: # end license list
+    licenses = gl.licenses.list(get_all=True)
 
-Generate a license content for a project:
+Generate a license content for a project::
 
-.. literalinclude:: templates.py
-   :start-after: # license get
-   :end-before: # end license get
+    license = gl.licenses.get('apache-2.0', project='foobar', fullname='John Doe')
+    print(license.content)
 
 .gitignore templates
 ====================
 
-* Object class: :class:`~gitlab.objects.Gitignore`
-* Manager object: :attr:`gitlab.Gitlab.gitognores`
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.Gitignore`
+  + :class:`gitlab.v4.objects.GitignoreManager`
+  + :attr:`gitlab.Gitlab.gitignores`
+
+* GitLab API: https://docs.gitlab.com/api/templates/gitignores
 
 Examples
 --------
 
-List known gitignore templates:
+List known gitignore templates::
 
-.. literalinclude:: templates.py
-   :start-after: # gitignore list
-   :end-before: # end gitignore list
+    gitignores = gl.gitignores.list(get_all=True)
 
-Get a gitignore template:
+Get a gitignore template::
 
-.. literalinclude:: templates.py
-   :start-after: # gitignore get
-   :end-before: # end gitignore get
+    gitignore = gl.gitignores.get('Python')
+    print(gitignore.content)
 
 GitLab CI templates
 ===================
 
-* Object class: :class:`~gitlab.objects.Gitlabciyml`
-* Manager object: :attr:`gitlab.Gitlab.gitlabciymls`
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.Gitlabciyml`
+  + :class:`gitlab.v4.objects.GitlabciymlManager`
+  + :attr:`gitlab.Gitlab.gitlabciymls`
+
+* GitLab API: https://docs.gitlab.com/api/templates/gitlab_ci_ymls
+
+Examples
+--------
+
+List known GitLab CI templates::
+
+    gitlabciymls = gl.gitlabciymls.list(get_all=True)
+
+Get a GitLab CI template::
+
+    gitlabciyml = gl.gitlabciymls.get('Pelican')
+    print(gitlabciyml.content)
+
+Dockerfile templates
+====================
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.Dockerfile`
+  + :class:`gitlab.v4.objects.DockerfileManager`
+  + :attr:`gitlab.Gitlab.gitlabciymls`
+
+* GitLab API: https://docs.gitlab.com/api/templates/dockerfiles
 
 Examples
 --------
 
-List known GitLab CI templates:
+List known Dockerfile templates::
+
+    dockerfiles = gl.dockerfiles.list(get_all=True)
+
+Get a Dockerfile template::
 
-.. literalinclude:: templates.py
-   :start-after: # gitlabciyml list
-   :end-before: # end gitlabciyml list
+    dockerfile = gl.dockerfiles.get('Python')
+    print(dockerfile.content)
 
-Get a GitLab CI template:
+Project templates
+=========================
+
+These templates are project-specific versions of the templates above, as
+well as issue and merge request templates.
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectLicenseTemplate`
+  + :class:`gitlab.v4.objects.ProjectLicenseTemplateManager`
+  + :attr:`gitlab.v4.objects.Project.license_templates`
+  + :class:`gitlab.v4.objects.ProjectGitignoreTemplate`
+  + :class:`gitlab.v4.objects.ProjectGitignoreTemplateManager`
+  + :attr:`gitlab.v4.objects.Project.gitignore_templates`
+  + :class:`gitlab.v4.objects.ProjectGitlabciymlTemplate`
+  + :class:`gitlab.v4.objects.ProjectGitlabciymlTemplateManager`
+  + :attr:`gitlab.v4.objects.Project.gitlabciyml_templates`
+  + :class:`gitlab.v4.objects.ProjectDockerfileTemplate`
+  + :class:`gitlab.v4.objects.ProjectDockerfileTemplateManager`
+  + :attr:`gitlab.v4.objects.Project.dockerfile_templates`
+  + :class:`gitlab.v4.objects.ProjectIssueTemplate`
+  + :class:`gitlab.v4.objects.ProjectIssueTemplateManager`
+  + :attr:`gitlab.v4.objects.Project.issue_templates`
+  + :class:`gitlab.v4.objects.ProjectMergeRequestTemplate`
+  + :class:`gitlab.v4.objects.ProjectMergeRequestTemplateManager`
+  + :attr:`gitlab.v4.objects.Project.merge_request_templates`
+
+* GitLab API: https://docs.gitlab.com/api/project_templates
+
+Examples
+--------
 
-.. literalinclude:: templates.py
-   :start-after: # gitlabciyml get
-   :end-before: # end gitlabciyml get
+List known project templates::
+
+    license_templates = project.license_templates.list(get_all=True)
+    gitignore_templates = project.gitignore_templates.list(get_all=True)
+    gitlabciyml_templates = project.gitlabciyml_templates.list(get_all=True)
+    dockerfile_templates = project.dockerfile_templates.list(get_all=True)
+    issue_templates = project.issue_templates.list(get_all=True)
+    merge_request_templates = project.merge_request_templates.list(get_all=True)
+
+Get project templates::
+  
+      license_template = project.license_templates.get('apache-2.0')
+      gitignore_template = project.gitignore_templates.get('Python')
+      gitlabciyml_template = project.gitlabciyml_templates.get('Pelican')
+      dockerfile_template = project.dockerfile_templates.get('Python')
+      issue_template = project.issue_templates.get('Default')
+      merge_request_template = project.merge_request_templates.get('Default')
+
+      print(license_template.content)
+      print(gitignore_template.content)
+      print(gitlabciyml_template.content)
+      print(dockerfile_template.content)
+      print(issue_template.content)
+      print(merge_request_template.content)
+
+Create an issue or merge request using a description template::
+
+      issue = project.issues.create({'title': 'I have a bug',
+                                     'description': issue_template.content})
+      mr = project.mergerequests.create({'source_branch': 'cool_feature',
+                                        'target_branch': 'main',
+                                        'title': 'merge cool feature',
+                                        'description': merge_request_template.content})
+                          
diff --git a/docs/gl_objects/todos.py b/docs/gl_objects/todos.py
deleted file mode 100644
index 74ec211ca..000000000
--- a/docs/gl_objects/todos.py
+++ /dev/null
@@ -1,22 +0,0 @@
-# list
-todos = gl.todos.list()
-# end list
-
-# filter
-todos = gl.todos.list(project_id=1)
-todos = gl.todos.list(state='done', type='Issue')
-# end filter
-
-# get
-todo = gl.todos.get(todo_id)
-# end get
-
-# delete
-gl.todos.delete(todo_id)
-# or
-todo.delete()
-# end delete
-
-# all_delete
-nb_of_closed_todos = gl.todos.delete_all()
-# end all_delete
diff --git a/docs/gl_objects/todos.rst b/docs/gl_objects/todos.rst
index bd7f1faea..821c60636 100644
--- a/docs/gl_objects/todos.rst
+++ b/docs/gl_objects/todos.rst
@@ -2,17 +2,23 @@
 Todos
 #####
 
-Use :class:`~gitlab.objects.Todo` objects to manipulate todos. The
-:attr:`gitlab.Gitlab.todos` manager object provides helper functions.
+Reference
+---------
+
+* v4 API:
+
+  + :class:`~gitlab.objects.Todo`
+  + :class:`~gitlab.objects.TodoManager`
+  + :attr:`gitlab.Gitlab.todos`
+
+* GitLab API: https://docs.gitlab.com/api/todos
 
 Examples
 --------
 
-List active todos:
+List active todos::
 
-.. literalinclude:: todos.py
-   :start-after: # list
-   :end-before: # end list
+    todos = gl.todos.list(get_all=True)
 
 You can filter the list using the following parameters:
 
@@ -23,26 +29,16 @@ You can filter the list using the following parameters:
 * ``state``: can be ``pending`` or ``done``
 * ``type``: can be ``Issue`` or ``MergeRequest``
 
-For example:
-
-.. literalinclude:: todos.py
-   :start-after: # filter
-   :end-before: # end filter
-
-Get a single todo:
+For example::
 
-.. literalinclude:: todos.py
-   :start-after: # get
-   :end-before: # end get
+    todos = gl.todos.list(project_id=1, get_all=True)
+    todos = gl.todos.list(state='done', type='Issue', get_all=True)
 
-Mark a todo as done:
+Mark a todo as done::
 
-.. literalinclude:: todos.py
-   :start-after: # delete
-   :end-before: # end delete
+    todos = gl.todos.list(project_id=1, get_all=True)
+    todos[0].mark_as_done()
 
-Mark all the todos as done:
+Mark all the todos as done::
 
-.. literalinclude:: todos.py
-   :start-after: # all_delete
-   :end-before: # end all_delete
+    gl.todos.mark_all_as_done()
diff --git a/docs/gl_objects/topics.rst b/docs/gl_objects/topics.rst
new file mode 100644
index 000000000..35e12d838
--- /dev/null
+++ b/docs/gl_objects/topics.rst
@@ -0,0 +1,65 @@
+########
+Topics
+########
+
+Topics can be used to categorize projects and find similar new projects. 
+
+Reference
+---------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.Topic`
+  + :class:`gitlab.v4.objects.TopicManager`
+  + :attr:`gitlab.Gitlab.topics`
+
+* GitLab API: https://docs.gitlab.com/api/topics
+
+This endpoint requires admin access for creating, updating and deleting objects.
+
+Examples
+--------
+
+List project topics on the GitLab instance::
+
+    topics = gl.topics.list(get_all=True)
+
+Get a specific topic by its ID::
+
+    topic = gl.topics.get(topic_id)
+
+Create a new topic::
+
+    topic = gl.topics.create({"name": "my-topic", "title": "my title"})
+
+Update a topic::
+
+    topic.description = "My new topic"
+    topic.save()
+
+    # or
+    gl.topics.update(topic_id, {"description": "My new topic"})
+
+Delete a topic::
+
+    topic.delete()
+
+    # or
+    gl.topics.delete(topic_id)
+
+Merge a source topic into a target topic::
+
+    gl.topics.merge(topic_id, target_topic_id)
+
+Set the avatar image for a topic::
+
+    # the avatar image can be passed as data (content of the file) or as a file
+    # object opened in binary mode
+    topic.avatar = open('path/to/file.png', 'rb')
+    topic.save()
+
+Remove the avatar image for a topic::
+
+    topic.avatar = ""
+    topic.save()
+
diff --git a/docs/gl_objects/users.py b/docs/gl_objects/users.py
deleted file mode 100644
index 798678d13..000000000
--- a/docs/gl_objects/users.py
+++ /dev/null
@@ -1,132 +0,0 @@
-# list
-users = gl.users.list()
-# end list
-
-# search
-users = gl.users.list(search='oo')
-# end search
-
-# get
-# by ID
-user = gl.users.get(2)
-# by username
-user = gl.users.list(username='root')[0]
-# end get
-
-# create
-user = gl.users.create({'email': 'john@doe.com',
-                        'password': 's3cur3s3cr3T',
-                        'username': 'jdoe',
-                        'name': 'John Doe'})
-# end create
-
-# update
-user.name = 'Real Name'
-user.save()
-# end update
-
-# delete
-gl.users.delete(2)
-user.delete()
-# end delete
-
-# block
-user.block()
-user.unblock()
-# end block
-
-# key list
-keys = gl.user_keys.list(user_id=1)
-# or
-keys = user.keys.list()
-# end key list
-
-# key get
-key = gl.user_keys.list(1, user_id=1)
-# or
-key = user.keys.get(1)
-# end key get
-
-# key create
-k = gl.user_keys.create({'title': 'my_key',
-                         'key': open('/home/me/.ssh/id_rsa.pub').read()},
-                        user_id=2)
-# or
-k = user.keys.create({'title': 'my_key',
-                      'key': open('/home/me/.ssh/id_rsa.pub').read()})
-# end key create
-
-# key delete
-gl.user_keys.delete(1, user_id=1)
-# or
-user.keys.delete(1)
-# or
-key.delete()
-# end key delete
-
-# email list
-emails = gl.user_emails.list(user_id=1)
-# or
-emails = user.emails.list()
-# end email list
-
-# email get
-email = gl.user_emails.list(1, user_id=1)
-# or
-email = user.emails.get(1)
-# end email get
-
-# email create
-k = gl.user_emails.create({'email': 'foo@bar.com'}, user_id=2)
-# or
-k = user.emails.create({'email': 'foo@bar.com'})
-# end email create
-
-# email delete
-gl.user_emails.delete(1, user_id=1)
-# or
-user.emails.delete(1)
-# or
-email.delete()
-# end email delete
-
-# currentuser get
-gl.auth()
-current_user = gl.user
-# end currentuser get
-
-# currentuser key list
-keys = gl.user.keys.list()
-# end currentuser key list
-
-# currentuser key get
-key = gl.user.keys.get(1)
-# end currentuser key get
-
-# currentuser key create
-key = gl.user.keys.create({'id': 'my_key', 'key': key_content})
-# end currentuser key create
-
-# currentuser key delete
-gl.user.keys.delete(1)
-# or
-key.delete()
-# end currentuser key delete
-
-# currentuser email list
-emails = gl.user.emails.list()
-# end currentuser email list
-
-# currentuser email get
-email = gl.user.emails.get(1)
-# end currentuser email get
-
-# currentuser email create
-email = gl.user.emails.create({'email': 'foo@bar.com'})
-# end currentuser email create
-
-# currentuser email delete
-gl.user.emails.delete(1)
-# or
-email.delete()
-# end currentuser email delete
diff --git a/docs/gl_objects/users.rst b/docs/gl_objects/users.rst
index 8df93b03f..5ebfa296b 100644
--- a/docs/gl_objects/users.rst
+++ b/docs/gl_objects/users.rst
@@ -1,197 +1,519 @@
-#####
+.. _users_examples:
+
+######################
+Users and current user
+######################
+
+The Gitlab API exposes user-related method that can be manipulated by admins
+only.
+
+The currently logged-in user is also exposed.
+
 Users
-#####
+=====
+
+References
+----------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.User`
+  + :class:`gitlab.v4.objects.UserManager`
+  + :attr:`gitlab.Gitlab.users`
+
+* GitLab API:
+
+  + https://docs.gitlab.com/api/users
+  + https://docs.gitlab.com/api/projects#list-projects-starred-by-a-user
+
+Examples
+--------
+
+Get the list of users::
+
+    users = gl.users.list(get_all=True)
+
+Search users whose username match a given string::
+
+    users = gl.users.list(search='foo', get_all=True)
+
+Get a single user::
+
+    # by ID
+    user = gl.users.get(user_id)
+    # by username
+    user = gl.users.list(username='root', get_all=False)[0]
+
+Create a user::
+
+    user = gl.users.create({'email': 'john@doe.com',
+                            'password': 's3cur3s3cr3T',
+                            'username': 'jdoe',
+                            'name': 'John Doe'})
+
+Update a user::
+
+    user.name = 'Real Name'
+    user.save()
+
+Delete a user::
+
+    gl.users.delete(user_id)
+    # or
+    user.delete()
+
+Block/Unblock a user::
+
+    user.block()
+    user.unblock()
+
+Activate/Deactivate a user::
+
+    user.activate()
+    user.deactivate()
+
+Ban/Unban a user::
+
+    user.ban()
+    user.unban()
+
+Follow/Unfollow a user::
+
+    user.follow()
+    user.unfollow()
+
+Set the avatar image for a user::
+
+    # the avatar image can be passed as data (content of the file) or as a file
+    # object opened in binary mode
+    user.avatar = open('path/to/file.png', 'rb')
+    user.save()
+
+Set an external identity for a user::
+
+    user.provider = 'oauth2_generic'
+    user.extern_uid = '3'
+    user.save()
+
+Delete an external identity by provider name::
+
+    user.identityproviders.delete('oauth2_generic')
+
+Get the followers of a user::
+
+    user.followers_users.list(get_all=True)
+
+Get the followings of a user::
+
+    user.following_users.list(get_all=True)
+
+List a user's contributed projects::
+
+    user.contributed_projects.list(get_all=True)
+
+List a user's starred projects::
+
+    user.starred_projects.list(get_all=True)
+
+If the GitLab instance has new user account approval enabled some users may
+have ``user.state == 'blocked_pending_approval'``. Administrators can approve
+and reject such users::
+
+    user.approve()
+    user.reject()
+
+User custom attributes
+======================
+
+References
+----------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.UserCustomAttribute`
+  + :class:`gitlab.v4.objects.UserCustomAttributeManager`
+  + :attr:`gitlab.v4.objects.User.customattributes`
+
+* GitLab API: https://docs.gitlab.com/api/custom_attributes
+
+Examples
+--------
+
+List custom attributes for a user::
+
+    attrs = user.customattributes.list(get_all=True)
+
+Get a custom attribute for a user::
+
+    attr = user.customattributes.get(attr_key)
+
+Set (create or update) a custom attribute for a user::
+
+    attr = user.customattributes.set(attr_key, attr_value)
+
+Delete a custom attribute for a user::
+
+    attr.delete()
+    # or
+    user.customattributes.delete(attr_key)
+
+Search users by custom attribute::
+
+    user.customattributes.set('role', 'QA')
+    gl.users.list(custom_attributes={'role': 'QA'}, get_all=True)
+
+User impersonation tokens
+=========================
+
+References
+----------
+
+* v4 API:
 
-Use :class:`~gitlab.objects.User` objects to manipulate repository branches.
+  + :class:`gitlab.v4.objects.UserImpersonationToken`
+  + :class:`gitlab.v4.objects.UserImpersonationTokenManager`
+  + :attr:`gitlab.v4.objects.User.impersonationtokens`
 
-To create :class:`~gitlab.objects.User` objects use the
-:attr:`gitlab.Gitlab.users` manager.
+* GitLab API: https://docs.gitlab.com/api/user_tokens#get-all-impersonation-tokens-of-a-user
+
+List impersonation tokens for a user::
+
+    i_t = user.impersonationtokens.list(state='active', get_all=True)
+    i_t = user.impersonationtokens.list(state='inactive', get_all=True)
+
+Get an impersonation token for a user::
+
+    i_t = user.impersonationtokens.get(i_t_id)
+
+Create and use an impersonation token for a user::
+
+    i_t = user.impersonationtokens.create({'name': 'token1', 'scopes': ['api']})
+    # use the token to create a new gitlab connection
+    user_gl = gitlab.Gitlab(gitlab_url, private_token=i_t.token)
+
+Revoke (delete) an impersonation token for a user::
+
+    i_t.delete()
+
+
+User projects
+=========================
+
+References
+----------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.UserProject`
+  + :class:`gitlab.v4.objects.UserProjectManager`
+  + :attr:`gitlab.v4.objects.User.projects`
+
+* GitLab API: https://docs.gitlab.com/api/projects#list-a-users-projects
+
+List visible projects in the user's namespace::
+
+    projects = user.projects.list(get_all=True)
+
+.. note::
+
+    Only the projects in the user’s namespace are returned. Projects owned by
+    the user in any group or subgroups are not returned. An empty list is
+    returned if a profile is set to private.
+
+
+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/api/users#list-projects-and-groups-that-a-user-is-a-member-of
+
+List direct memberships for a user::
+
+    memberships = user.memberships.list(get_all=True)
+
+List only direct project memberships::
+
+    memberships = user.memberships.list(type='Project', get_all=True)
+
+List only direct group memberships::
+
+    memberships = user.memberships.list(type='Namespace', get_all=True)
+
+.. note::
+
+    This endpoint requires admin access.
+
+Current User
+============
+
+References
+----------
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.CurrentUser`
+  + :class:`gitlab.v4.objects.CurrentUserManager`
+  + :attr:`gitlab.Gitlab.user`
+
+* GitLab API: https://docs.gitlab.com/api/users
 
 Examples
+--------
+
+Get the current user::
+
+    gl.auth()
+    current_user = gl.user
+
+GPG keys
 ========
 
-Get the list of users:
+References
+----------
 
-.. literalinclude:: users.py
-   :start-after: # list
-   :end-before: # end list
+You can manipulate GPG keys for the current user and for the other users if you
+are admin.
 
-Search users whose username match the given string:
+* v4 API:
 
-.. literalinclude:: users.py
-   :start-after: # search
-   :end-before: # end search
+  + :class:`gitlab.v4.objects.CurrentUserGPGKey`
+  + :class:`gitlab.v4.objects.CurrentUserGPGKeyManager`
+  + :attr:`gitlab.v4.objects.CurrentUser.gpgkeys`
+  + :class:`gitlab.v4.objects.UserGPGKey`
+  + :class:`gitlab.v4.objects.UserGPGKeyManager`
+  + :attr:`gitlab.v4.objects.User.gpgkeys`
 
-Get a single user:
+* GitLab API: https://docs.gitlab.com/api/user_keys#list-your-gpg-keys
 
-.. literalinclude:: users.py
-   :start-after: # get
-   :end-before: # end get
+Examples
+--------
 
-Create a user:
+List GPG keys for a user::
 
-.. literalinclude:: users.py
-   :start-after: # create
-   :end-before: # end create
+    gpgkeys = user.gpgkeys.list(get_all=True)
 
-Update a user:
+Get a GPG gpgkey for a user::
 
-.. literalinclude:: users.py
-   :start-after: # update
-   :end-before: # end update
+    gpgkey = user.gpgkeys.get(key_id)
 
-Delete a user:
+Create a GPG gpgkey for a user::
 
-.. literalinclude:: users.py
-   :start-after: # delete
-   :end-before: # end delete
+    # get the key with `gpg --export -a GPG_KEY_ID`
+    k = user.gpgkeys.create({'key': public_key_content})
 
-Block/Unblock a user:
+Delete a GPG gpgkey for a user::
 
-.. literalinclude:: users.py
-   :start-after: # block
-   :end-before: # end block
+    user.gpgkeys.delete(key_id)
+    # or
+    gpgkey.delete()
 
 SSH keys
 ========
 
-Use the :class:`~gitlab.objects.UserKey` objects to manage user keys.
+References
+----------
+
+You can manipulate SSH keys for the current user and for the other users if you
+are admin.
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.CurrentUserKey`
+  + :class:`gitlab.v4.objects.CurrentUserKeyManager`
+  + :attr:`gitlab.v4.objects.CurrentUser.keys`
+  + :class:`gitlab.v4.objects.UserKey`
+  + :class:`gitlab.v4.objects.UserKeyManager`
+  + :attr:`gitlab.v4.objects.User.keys`
 
-To create :class:`~gitlab.objects.UserKey` objects use the
-:attr:`User.keys <gitlab.objects.User.keys>` or :attr:`gitlab.Gitlab.user_keys`
-managers.
+* GitLab API: https://docs.gitlab.com/api/user_keys#get-a-single-ssh-key
 
-Exemples
+Examples
 --------
 
-List SSH keys for a user:
+List SSH keys for a user::
 
-.. literalinclude:: users.py
-   :start-after: # key list
-   :end-before: # end key list
+    keys = user.keys.list(get_all=True)
 
-Get an SSH key for a user:
+Create an SSH key for a user::
 
-.. literalinclude:: users.py
-   :start-after: # key get
-   :end-before: # end key get
+    key = user.keys.create({'title': 'my_key',
+                          'key': open('/home/me/.ssh/id_rsa.pub').read()})
 
-Create an SSH key for a user:
+Get an SSH key for a user by id::
 
-.. literalinclude:: users.py
-   :start-after: # key create
-   :end-before: # end key create
+    key = user.keys.get(key_id)
 
-Delete an SSH key for a user:
+Delete an SSH key for a user::
 
-.. literalinclude:: users.py
-   :start-after: # key delete
-   :end-before: # end key delete
+    user.keys.delete(key_id)
+    # or
+    key.delete()
 
-Emails
+Status
 ======
 
-Use the :class:`~gitlab.objects.UserEmail` objects to manage user emails.
+References
+----------
 
-To create :class:`~gitlab.objects.UserEmail` objects use the :attr:`User.emails
-<gitlab.objects.User.emails>` or :attr:`gitlab.Gitlab.user_emails` managers.
+You can manipulate the status for the current user and you can read the status of other users.
 
-Exemples
---------
+* v4 API:
+
+  + :class:`gitlab.v4.objects.CurrentUserStatus`
+  + :class:`gitlab.v4.objects.CurrentUserStatusManager`
+  + :attr:`gitlab.v4.objects.CurrentUser.status`
+  + :class:`gitlab.v4.objects.UserStatus`
+  + :class:`gitlab.v4.objects.UserStatusManager`
+  + :attr:`gitlab.v4.objects.User.status`
 
-List emails for a user:
+* GitLab API: https://docs.gitlab.com/api/users#get-the-status-of-a-user
 
-.. literalinclude:: users.py
-   :start-after: # email list
-   :end-before: # end email list
+Examples
+--------
 
-Get an email for a user:
+Get current user status::
 
-.. literalinclude:: users.py
-   :start-after: # email get
-   :end-before: # end email get
+    status = user.status.get()
 
-Create an email for a user:
+Update the status for the current user::
 
-.. literalinclude:: users.py
-   :start-after: # email create
-   :end-before: # end email create
+    status = user.status.get()
+    status.message = "message"
+    status.emoji = "thumbsup"
+    status.save()
 
-Delete an email for a user:
+Get the status of other users::
 
-.. literalinclude:: users.py
-   :start-after: # email delete
-   :end-before: # end email delete
+    gl.users.get(1).status.get()
 
-Current User
-============
+Emails
+======
 
-Use the :class:`~gitlab.objects.CurrentUser` object to get information about
-the currently logged-in user.
+References
+----------
 
-Use the :class:`~gitlab.objects.CurrentUserKey` objects to manage user keys.
+You can manipulate emails for the current user and for the other users if you
+are admin.
 
-To create :class:`~gitlab.objects.CurrentUserKey` objects use the
-:attr:`gitlab.objects.CurrentUser.keys <CurrentUser.keys>` manager.
+* v4 API:
 
-Use the :class:`~gitlab.objects.CurrentUserEmail` objects to manage user emails.
+  + :class:`gitlab.v4.objects.CurrentUserEmail`
+  + :class:`gitlab.v4.objects.CurrentUserEmailManager`
+  + :attr:`gitlab.v4.objects.CurrentUser.emails`
+  + :class:`gitlab.v4.objects.UserEmail`
+  + :class:`gitlab.v4.objects.UserEmailManager`
+  + :attr:`gitlab.v4.objects.User.emails`
 
-To create :class:`~gitlab.objects.CurrentUserEmail` objects use the
-:attr:`gitlab.objects.CurrentUser.emails <CurrentUser.emails>` manager.
+* GitLab API: https://docs.gitlab.com/api/user_email_addresses
 
 Examples
 --------
 
-Get the current user:
+List emails for a user::
+
+    emails = user.emails.list(get_all=True)
 
-.. literalinclude:: users.py
-   :start-after: # currentuser get
-   :end-before: # end currentuser get
+Get an email for a user::
 
-List the current user SSH keys:
+    email = user.emails.get(email_id)
 
-.. literalinclude:: users.py
-   :start-after: # currentuser key list
-   :end-before: # end currentuser key list
+Create an email for a user::
 
-Get a key for the current user:
+    k = user.emails.create({'email': 'foo@bar.com'})
 
-.. literalinclude:: users.py
-   :start-after: # currentuser key get
-   :end-before: # end currentuser key get
+Delete an email for a user::
 
-Create a key for the current user:
+    user.emails.delete(email_id)
+    # or
+    email.delete()
 
-.. literalinclude:: users.py
-   :start-after: # currentuser key create
-   :end-before: # end currentuser key create
+Users activities
+================
 
-Delete a key for the current user:
+References
+----------
 
-.. literalinclude:: users.py
-   :start-after: # currentuser key delete
-   :end-before: # end currentuser key delete
+* admin only
 
-List the current user emails:
+* v4 API:
 
-.. literalinclude:: users.py
-   :start-after: # currentuser email list
-   :end-before: # end currentuser email list
+  + :class:`gitlab.v4.objects.UserActivities`
+  + :class:`gitlab.v4.objects.UserActivitiesManager`
+  + :attr:`gitlab.Gitlab.user_activities`
 
-Get an email for the current user:
+* GitLab API: https://docs.gitlab.com/api/users#list-a-users-activity
 
-.. literalinclude:: users.py
-   :start-after: # currentuser email get
-   :end-before: # end currentuser email get
+Examples
+--------
 
-Create an email for the current user:
+Get the users activities::
 
-.. literalinclude:: users.py
-   :start-after: # currentuser email create
-   :end-before: # end currentuser email create
+    activities = gl.user_activities.list(
+        query_parameters={'from': '2018-07-01'},
+        get_all=True,
+    )
 
-Delete an email for the current user:
+Create new runner
+=================
+
+References
+----------
+
+* New runner registration API endpoint (see `Migrating to the new runner registration workflow <https://docs.gitlab.com/ci/runners/new_creation_workflow#creating-runners-programmatically>`_)
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.CurrentUserRunner`
+  + :class:`gitlab.v4.objects.CurrentUserRunnerManager`
+  + :attr:`gitlab.Gitlab.user.runners`
+
+* GitLab API : https://docs.gitlab.com/api/users#create-a-runner-linked-to-a-user
+
+Examples
+--------
 
-.. literalinclude:: users.py
-   :start-after: # currentuser email delete
-   :end-before: # end currentuser email delete
+Create an instance-wide runner::
+
+    runner = gl.user.runners.create({
+        "runner_type": "instance_type",
+        "description": "My brand new runner",
+        "paused": True,
+        "locked": False,
+        "run_untagged": True,
+        "tag_list": ["linux", "docker", "testing"],
+        "access_level": "not_protected"
+    })
+
+Create a group runner::
+
+    runner = gl.user.runners.create({
+        "runner_type": "group_type",
+        "group_id": 12345678,
+        "description": "My brand new runner",
+        "paused": True,
+        "locked": False,
+        "run_untagged": True,
+        "tag_list": ["linux", "docker", "testing"],
+        "access_level": "not_protected"
+    })
+
+Create a project runner::
+
+    runner = gl.user.runners.create({
+        "runner_type": "project_type",
+        "project_id": 987564321,
+        "description": "My brand new runner",
+        "paused": True,
+        "locked": False,
+        "run_untagged": True,
+        "tag_list": ["linux", "docker", "testing"],
+        "access_level": "not_protected"
+    })
diff --git a/docs/gl_objects/variables.rst b/docs/gl_objects/variables.rst
new file mode 100644
index 000000000..4fd3255a2
--- /dev/null
+++ b/docs/gl_objects/variables.rst
@@ -0,0 +1,135 @@
+###############
+CI/CD Variables
+###############
+
+You can configure variables at the instance-level (admin only), or associate
+variables to projects and groups, to modify pipeline/job scripts behavior.
+
+.. warning::
+
+    Please always follow GitLab's `rules for CI/CD variables`_, especially for values
+    in masked variables. If you do not, your variables may silently fail to save.
+
+.. _rules for CI/CD variables: https://docs.gitlab.com/ci/variables/#add-a-cicd-variable-to-a-project
+
+Instance-level variables
+========================
+
+This endpoint requires admin access.
+
+Reference
+---------
+
+* v4 API
+
+  + :class:`gitlab.v4.objects.Variable`
+  + :class:`gitlab.v4.objects.VariableManager`
+  + :attr:`gitlab.Gitlab.variables`
+
+* GitLab API
+
+  + https://docs.gitlab.com/api/instance_level_ci_variables
+
+Examples
+--------
+
+List all instance variables::
+
+    variables = gl.variables.list(get_all=True)
+
+Get an instance variable by key::
+
+    variable = gl.variables.get('key_name')
+
+Create an instance variable::
+
+    variable = gl.variables.create({'key': 'key1', 'value': 'value1'})
+
+Update a variable value::
+
+    variable.value = 'new_value'
+    variable.save()
+
+Remove a variable::
+
+    gl.variables.delete('key_name')
+    # or
+    variable.delete()
+
+Projects and groups variables
+=============================
+
+Reference
+---------
+
+* v4 API
+
+  + :class:`gitlab.v4.objects.ProjectVariable`
+  + :class:`gitlab.v4.objects.ProjectVariableManager`
+  + :attr:`gitlab.v4.objects.Project.variables`
+  + :class:`gitlab.v4.objects.GroupVariable`
+  + :class:`gitlab.v4.objects.GroupVariableManager`
+  + :attr:`gitlab.v4.objects.Group.variables`
+
+* GitLab API
+
+  + https://docs.gitlab.com/api/instance_level_ci_variables
+  + https://docs.gitlab.com/api/project_level_variables
+  + https://docs.gitlab.com/api/group_level_variables
+
+Examples
+--------
+
+List variables::
+
+    p_variables = project.variables.list(get_all=True)
+    g_variables = group.variables.list(get_all=True)
+
+Get a variable::
+
+    p_var = project.variables.get('key_name')
+    g_var = group.variables.get('key_name')
+
+.. note::
+
+   If there are multiple variables with the same key, use ``filter`` to select
+   the correct ``environment_scope``. See the GitLab API docs for more
+   information.
+
+Create a variable::
+
+    var = project.variables.create({'key': 'key1', 'value': 'value1'})
+    var = group.variables.create({'key': 'key1', 'value': 'value1'})
+
+.. note::
+
+   If a variable with the same key already exists, the new variable must have a
+   different ``environment_scope``. Otherwise, GitLab returns a message similar
+   to: ``VARIABLE_NAME has already been taken``. See the GitLab API docs for
+   more information.
+
+Update a variable value::
+
+    var.value = 'new_value'
+    var.save()
+    # or
+    project.variables.update("key1", {"value": "new_value"})
+
+.. note::
+
+   If there are multiple variables with the same key, use ``filter`` to select
+   the correct ``environment_scope``. See the GitLab API docs for more
+   information.
+
+Remove a variable::
+
+    project.variables.delete('key_name')
+    group.variables.delete('key_name')
+    # or
+    var.delete()
+
+.. note::
+
+   If there are multiple variables with the same key, use ``filter`` to select
+   the correct ``environment_scope``. See the GitLab API docs for more
+   information.
diff --git a/docs/gl_objects/wikis.rst b/docs/gl_objects/wikis.rst
new file mode 100644
index 000000000..d9b747eb5
--- /dev/null
+++ b/docs/gl_objects/wikis.rst
@@ -0,0 +1,94 @@
+##########
+Wiki pages
+##########
+
+
+References
+==========
+
+* v4 API:
+
+  + :class:`gitlab.v4.objects.ProjectWiki`
+  + :class:`gitlab.v4.objects.ProjectWikiManager`
+  + :attr:`gitlab.v4.objects.Project.wikis`
+  + :class:`gitlab.v4.objects.GroupWiki`
+  + :class:`gitlab.v4.objects.GroupWikiManager`
+  + :attr:`gitlab.v4.objects.Group.wikis`
+
+* GitLab API for Projects: https://docs.gitlab.com/api/wikis
+* GitLab API for Groups: https://docs.gitlab.com/api/group_wikis
+
+Examples
+--------
+
+Get the list of wiki pages for a project. These do not contain the contents of the wiki page. You will need to call get(slug) to retrieve the content by accessing the content attribute::
+
+    pages = project.wikis.list(get_all=True)
+
+Get the list of wiki pages for a group. These do not contain the contents of the wiki page. You will need to call get(slug) to retrieve the content by accessing the content attribute::
+
+    pages = group.wikis.list(get_all=True)
+
+Get a single wiki page for a project::
+
+    page = project.wikis.get(page_slug)
+
+Get a single wiki page for a group::
+
+    page = group.wikis.get(page_slug)
+
+Get the contents of a wiki page::
+
+    print(page.content)
+
+Create a wiki page on a project level::
+
+    page = project.wikis.create({'title': 'Wiki Page 1',
+                                 'content': open(a_file).read()})
+
+Update a wiki page::
+
+    page.content = 'My new content'
+    page.save()
+
+Delete a wiki page::
+
+    page.delete()
+
+
+File uploads
+============
+
+Reference
+---------
+
+* v4 API:
+
+  + :attr:`gitlab.v4.objects.ProjectWiki.upload`
+  + :attr:`gitlab.v4.objects.GrouptWiki.upload`
+
+
+* Gitlab API for Projects: https://docs.gitlab.com/api/wikis#upload-an-attachment-to-the-wiki-repository
+* Gitlab API for Groups: https://docs.gitlab.com/api/group_wikis#upload-an-attachment-to-the-wiki-repository
+
+Examples
+--------
+
+Upload a file into a project wiki using a filesystem path::
+
+    page = project.wikis.get(page_slug)
+    page.upload("filename.txt", filepath="/some/path/filename.txt")
+
+Upload a file into a project wiki with raw data::
+
+    page.upload("filename.txt", filedata="Raw data")
+
+Upload a file into a group wiki using a filesystem path::
+
+    page = group.wikis.get(page_slug)
+    page.upload("filename.txt", filepath="/some/path/filename.txt")
+
+Upload a file into a group wiki using raw data::
+
+    page.upload("filename.txt", filedata="Raw data")
+
diff --git a/docs/index.rst b/docs/index.rst
index 54472fe43..1d0a0ed53 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -1,22 +1,21 @@
-.. python-gitlab documentation master file, created by
-   sphinx-quickstart on Mon Dec  8 15:17:39 2014.
-   You can adapt this file completely to your liking, but it should at least
-   contain the root `toctree` directive.
-
-Welcome to python-gitlab's documentation!
-=========================================
-
-Contents:
+.. include:: ../README.rst
 
 .. toctree::
-   :maxdepth: 2
+   :caption: Table of Contents
+   :hidden:
 
-   install
-   cli
+   cli-usage
    api-usage
+   api-usage-advanced
+   api-usage-graphql
+   cli-examples
    api-objects
-   upgrade-from-0.10
-   api/modules
+   api/gitlab
+   cli-objects
+   api-levels
+   changelog
+   release-notes
+   faq
 
 
 Indices and tables
diff --git a/docs/install.rst b/docs/install.rst
deleted file mode 100644
index 6abba3f03..000000000
--- a/docs/install.rst
+++ /dev/null
@@ -1,21 +0,0 @@
-############
-Installation
-############
-
-``python-gitlab`` is compatible with python 2 and 3.
-
-Use :command:`pip` to install the latest stable version of ``python-gitlab``:
-
-.. code-block:: console
-
-   $ pip install --upgrade python-gitlab
-
-The current development version is available on `github
-<https://github.com/gpocentek/python-gitlab>`__. Use :command:`git` and
-:command:`pip` to install it:
-
-.. code-block:: console
-
-   $ git clone https://github.com/gpocentek/python-gitlab
-   $ cd python-gitlab
-   $ python setup.py install
diff --git a/docs/release-notes.rst b/docs/release-notes.rst
new file mode 100644
index 000000000..927d2c4dd
--- /dev/null
+++ b/docs/release-notes.rst
@@ -0,0 +1,221 @@
+#############
+Release notes
+#############
+
+Prior to version 2.0.0 and GitHub Releases, a summary of changes was maintained
+in release notes. They are available below for historical purposes.
+For the list of current releases, including breaking changes, please see the changelog.
+
+Changes from 1.8 to 1.9
+=======================
+
+* ``ProjectMemberManager.all()`` and ``GroupMemberManager.all()`` now return a
+  list of ``ProjectMember`` and ``GroupMember`` objects respectively, instead
+  of a list of dicts.
+
+Changes from 1.7 to 1.8
+=======================
+
+* You can now use the ``query_parameters`` argument in method calls to define
+  arguments to send to the GitLab server. This allows to avoid conflicts
+  between python-gitlab and GitLab server variables, and allows to use the
+  python reserved keywords as GitLab arguments.
+
+  The following examples make the same GitLab request with the 2 syntaxes::
+
+     projects = gl.projects.list(owned=True, starred=True)
+     projects = gl.projects.list(query_parameters={'owned': True, 'starred': True})
+
+  The following example only works with the new parameter::
+
+     activities = gl.user_activities.list(
+                    query_parameters={'from': '2019-01-01'},
+                    all=True)
+
+* Additionally the ``all`` paremeter is not sent to the GitLab anymore.
+
+Changes from 1.5 to 1.6
+=======================
+
+* When python-gitlab detects HTTP redirections from http to https it will raise
+  a RedirectionError instead of a cryptic error.
+
+  Make sure to use an ``https://`` protocol in your GitLab URL parameter if the
+  server requires it.
+
+Changes from 1.4 to 1.5
+=======================
+
+* APIv3 support has been removed. Use the 1.4 release/branch if you need v3
+  support.
+* GitLab EE features are now supported: Geo nodes, issue links, LDAP groups,
+  project/group boards, project mirror pulling, project push rules, EE license
+  configuration, epics.
+* The ``GetFromListMixin`` class has been removed. The ``get()`` method is not
+  available anymore for the following managers:
+
+  - UserKeyManager
+  - DeployKeyManager
+  - GroupAccessRequestManager
+  - GroupIssueManager
+  - GroupProjectManager
+  - GroupSubgroupManager
+  - IssueManager
+  - ProjectCommitStatusManager
+  - ProjectEnvironmentManager
+  - ProjectLabelManager
+  - ProjectPipelineJobManager
+  - ProjectAccessRequestManager
+  - TodoManager
+
+* ``ProjectPipelineJob`` do not heritate from ``ProjectJob`` anymore and thus
+  can only be listed.
+
+Changes from 1.3 to 1.4
+=======================
+
+* 1.4 is the last release supporting the v3 API, and the related code will be
+  removed in the 1.5 version.
+
+  If you are using a Gitlab server version that does not support the v4 API you
+  can:
+
+  * upgrade the server (recommended)
+  * make sure to use version 1.4 of python-gitlab (``pip install
+    python-gitlab==1.4``)
+
+  See also the `Switching to GitLab API v4 documentation
+  <http://python-gitlab.readthedocs.io/en/master/switching-to-v4.html>`__.
+* python-gitlab now handles the server rate limiting feature. It will pause for
+  the required time when reaching the limit (`documentation
+  <http://python-gitlab.readthedocs.io/en/master/api-usage.html#rate-limits>`__)
+* The ``GetFromListMixin.get()`` method is deprecated and will be removed in
+  the next python-gitlab version. The goal of this mixin/method is to provide a
+  way to get an object by looping through a list for GitLab objects that don't
+  support the GET method. The method `is broken
+  <https://github.com/python-gitlab/python-gitlab/issues/499>`__ and conflicts
+  with the GET method now supported by some GitLab objects.
+
+  You can implement your own method with something like:
+
+  .. code-block:: python
+
+     def get_from_list(self, id):
+         for obj in self.list(as_list=False):
+             if obj.get_id() == id:
+                 return obj
+
+* The ``GroupMemberManager``, ``NamespaceManager`` and ``ProjectBoardManager``
+  managers now use the GET API from GitLab instead of the
+  ``GetFromListMixin.get()`` method.
+
+
+Changes from 1.2 to 1.3
+=======================
+
+* ``gitlab.Gitlab`` objects can be used as context managers in a ``with``
+  block.
+
+Changes from 1.1 to 1.2
+=======================
+
+* python-gitlab now respects the ``*_proxy``, ``REQUESTS_CA_BUNDLE`` and
+  ``CURL_CA_BUNDLE`` environment variables (#352)
+* The following deprecated methods and objects have been removed:
+
+  * gitlab.v3.object ``Key`` and ``KeyManager`` objects: use ``DeployKey`` and
+    ``DeployKeyManager`` instead
+  * gitlab.v3.objects.Project ``archive_`` and ``unarchive_`` methods
+  * gitlab.Gitlab ``credentials_auth``, ``token_auth``, ``set_url``,
+    ``set_token`` and ``set_credentials`` methods. Once a Gitlab object has been
+    created its URL and authentication information cannot be updated: create a
+    new Gitlab object if you need to use new information
+* The ``todo()`` method raises a ``GitlabTodoError`` exception on error
+
+Changes from 1.0.2 to 1.1
+=========================
+
+* The ``ProjectUser`` class doesn't inherit from ``User`` anymore, and the
+  ``GroupProject`` class doesn't inherit from ``Project`` anymore. The Gitlab
+  API doesn't provide the same set of features for these objects, so
+  python-gitlab objects shouldn't try to workaround that.
+
+  You can create ``User`` or ``Project`` objects from ``ProjectUser`` and
+  ``GroupProject`` objects using the ``id`` attribute:
+
+  .. code-block:: python
+
+     for gr_project in group.projects.list():
+         # lazy object creation avoids a Gitlab API request
+         project = gl.projects.get(gr_project.id, lazy=True)
+         project.default_branch = 'develop'
+         project.save()
+
+Changes from 0.21 to 1.0.0
+==========================
+
+1.0.0 brings a stable python-gitlab API for the v4 Gitlab API. v3 is still used
+by default.
+
+v4 is mostly compatible with the v3, but some important changes have been
+introduced. Make sure to read `Switching to GitLab API v4
+<http://python-gitlab.readthedocs.io/en/master/switching-to-v4.html>`_.
+
+The development focus will be v4 from now on. v3 has been deprecated by GitLab
+and will disappear from python-gitlab at some point.
+
+Changes from 0.20 to 0.21
+=========================
+
+* Initial support for the v4 API (experimental)
+
+  The support for v4 is stable enough to be tested, but some features might be
+  broken. Please report issues to
+  https://github.com/python-gitlab/python-gitlab/issues/
+
+  Be aware that the python-gitlab API for v4 objects might change in the next
+  releases.
+
+  .. warning::
+
+     Consider defining explicitly which API version you want to use in the
+     configuration files or in your ``gitlab.Gitlab`` instances. The default
+     will change from v3 to v4 soon.
+
+* Several methods have been deprecated in the ``gitlab.Gitlab`` class:
+
+  + ``credentials_auth()`` is deprecated and will be removed. Call ``auth()``.
+  + ``token_auth()`` is deprecated and will be removed. Call ``auth()``.
+  + ``set_url()`` is deprecated, create a new ``Gitlab`` instance if you need
+    an updated URL.
+  + ``set_token()`` is deprecated, use the ``private_token`` argument of the
+    ``Gitlab`` constructor.
+  + ``set_credentials()`` is deprecated, use the ``email`` and ``password``
+    arguments of the ``Gitlab`` constructor.
+
+* The service listing method (``ProjectServiceManager.list()``) now returns a
+  python list instead of a JSON string.
+
+Changes from 0.19 to 0.20
+=========================
+
+* The ``projects`` attribute of ``Group`` objects is not a list of ``Project``
+  objects anymore. It is a Manager object giving access to ``GroupProject``
+  objects. To get the list of projects use:
+
+  .. code-block:: python
+
+     group.projects.list()
+
+  Documentation:
+  http://python-gitlab.readthedocs.io/en/stable/gl_objects/groups.html#examples
+
+  Related issue: https://github.com/python-gitlab/python-gitlab/issues/209
+
+* The ``Key`` objects are deprecated in favor of the new ``DeployKey`` objects.
+  They are exactly the same but the name makes more sense.
+
+  Documentation:
+  http://python-gitlab.readthedocs.io/en/stable/gl_objects/deploy_keys.html
+
+  Related issue: https://github.com/python-gitlab/python-gitlab/issues/212
diff --git a/docs/upgrade-from-0.10.rst b/docs/upgrade-from-0.10.rst
deleted file mode 100644
index 7ff80ab38..000000000
--- a/docs/upgrade-from-0.10.rst
+++ /dev/null
@@ -1,125 +0,0 @@
-#############################################
-Upgrading from python-gitlab 0.10 and earlier
-#############################################
-
-``python-gitlab`` 0.11 introduces new objects which make the API cleaner and
-easier to use. The feature set is unchanged but some methods have been
-deprecated in favor of the new manager objects.
-
-Deprecated methods will be remove in a future release.
-
-Gitlab object migration
-=======================
-
-The objects constructor methods are deprecated:
-
-* ``Hook()``
-* ``Project()``
-* ``UserProject()``
-* ``Group()``
-* ``Issue()``
-* ``User()``
-* ``Team()``
-
-Use the new managers objects instead. For example:
-
-.. code-block:: python
-
-   # Deprecated syntax
-   p1 = gl.Project({'name': 'myCoolProject'})
-   p1.save()
-   p2 = gl.Project(id=1)
-   p_list = gl.Project()
-
-   # New syntax
-   p1 = gl.projects.create({'name': 'myCoolProject'})
-   p2 = gl.projects.get(1)
-   p_list = gl.projects.list()
-
-The following methods are also deprecated:
-
-* ``search_projects()``
-* ``owned_projects()``
-* ``all_projects()``
-
-Use the ``projects`` manager instead:
-
-.. code-block:: python
-
-   # Deprecated syntax
-   l1 = gl.search_projects('whatever')
-   l2 = gl.owned_projects()
-   l3 = gl.all_projects()
-
-   # New syntax
-   l1 = gl.projects.search('whatever')
-   l2 = gl.projects.owned()
-   l3 = gl.projects.all()
-
-GitlabObject objects migration
-==============================
-
-The following constructor methods are deprecated in favor of the matching
-managers:
-
-.. list-table::
-   :header-rows: 1
-
-   * - Deprecated method
-     - Matching manager
-   * - ``User.Key()``
-     - ``User.keys``
-   * - ``CurrentUser.Key()``
-     - ``CurrentUser.keys``
-   * - ``Group.Member()``
-     - ``Group.members``
-   * - ``ProjectIssue.Note()``
-     - ``ProjectIssue.notes``
-   * - ``ProjectMergeRequest.Note()``
-     - ``ProjectMergeRequest.notes``
-   * - ``ProjectSnippet.Note()``
-     - ``ProjectSnippet.notes``
-   * - ``Project.Branch()``
-     - ``Project.branches``
-   * - ``Project.Commit()``
-     - ``Project.commits``
-   * - ``Project.Event()``
-     - ``Project.events``
-   * - ``Project.File()``
-     - ``Project.files``
-   * - ``Project.Hook()``
-     - ``Project.hooks``
-   * - ``Project.Key()``
-     - ``Project.keys``
-   * - ``Project.Issue()``
-     - ``Project.issues``
-   * - ``Project.Label()``
-     - ``Project.labels``
-   * - ``Project.Member()``
-     - ``Project.members``
-   * - ``Project.MergeRequest()``
-     - ``Project.mergerequests``
-   * - ``Project.Milestone()``
-     - ``Project.milestones``
-   * - ``Project.Note()``
-     - ``Project.notes``
-   * - ``Project.Snippet()``
-     - ``Project.snippets``
-   * - ``Project.Tag()``
-     - ``Project.tags``
-   * - ``Team.Member()``
-     - ``Team.members``
-   * - ``Team.Project()``
-     - ``Team.projects``
-
-For example:
-
-.. code-block:: python
-
-   # Deprecated syntax
-   p = gl.Project(id=2)
-   issues = p.Issue()
-
-   # New syntax
-   p = gl.projects.get(2)
-   issues = p.issues.list()
diff --git a/gitlab/__init__.py b/gitlab/__init__.py
index e0051aafd..e7a24cb1d 100644
--- a/gitlab/__init__.py
+++ b/gitlab/__init__.py
@@ -1,6 +1,5 @@
-# -*- coding: utf-8 -*-
 #
-# Copyright (C) 2013-2015 Gauvain Pocentek <gauvain@pocentek.net>
+# Copyright (C) 2013-2019 Gauvain Pocentek, 2019-2023 python-gitlab team
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Lesser General Public License as published by
@@ -16,540 +15,33 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 """Wrapper for the GitLab API."""
 
-from __future__ import print_function
-from __future__ import division
-from __future__ import absolute_import
-import inspect
-import itertools
-import json
-import re
 import warnings
 
-import requests
-import six
-
-import gitlab.config
-from gitlab.const import *  # noqa
-from gitlab.exceptions import *  # noqa
-from gitlab.objects import *  # noqa
-
-__title__ = 'python-gitlab'
-__version__ = '0.18'
-__author__ = 'Gauvain Pocentek'
-__email__ = 'gauvain@pocentek.net'
-__license__ = 'LGPL3'
-__copyright__ = 'Copyright 2013-2016 Gauvain Pocentek'
-
-warnings.filterwarnings('default', category=DeprecationWarning,
-                        module='^gitlab')
-
-
-def _sanitize(value):
-    if isinstance(value, dict):
-        return dict((k, _sanitize(v))
-                    for k, v in six.iteritems(value))
-    if isinstance(value, six.string_types):
-        return value.replace('/', '%2F')
-    return value
-
-
-class Gitlab(object):
-    """Represents a GitLab server connection.
-
-    Args:
-        url (str): The URL of the GitLab server.
-        private_token (str): The user private token
-        email (str): The user email or login.
-        password (str): The user password (associated with email).
-        ssl_verify (bool): Whether SSL certificates should be validated.
-        timeout (float): Timeout to use for requests to the GitLab server.
-        http_username (str): Username for HTTP authentication
-        http_password (str): Password for HTTP authentication
-    """
-
-    def __init__(self, url, private_token=None, email=None, password=None,
-                 ssl_verify=True, http_username=None, http_password=None,
-                 timeout=None):
-
-        self._url = '%s/api/v3' % url
-        #: Timeout to use for requests to gitlab server
-        self.timeout = timeout
-        #: Headers that will be used in request to GitLab
-        self.headers = {}
-        self.set_token(private_token)
-        #: The user email
-        self.email = email
-        #: The user password (associated with email)
-        self.password = password
-        #: Whether SSL certificates should be validated
-        self.ssl_verify = ssl_verify
-        self.http_username = http_username
-        self.http_password = http_password
-
-        #: Create a session object for requests
-        self.session = requests.Session()
-
-        self.broadcastmessages = BroadcastMessageManager(self)
-        self.keys = KeyManager(self)
-        self.gitlabciymls = GitlabciymlManager(self)
-        self.gitignores = GitignoreManager(self)
-        self.groups = GroupManager(self)
-        self.hooks = HookManager(self)
-        self.issues = IssueManager(self)
-        self.licenses = LicenseManager(self)
-        self.namespaces = NamespaceManager(self)
-        self.notificationsettings = NotificationSettingsManager(self)
-        self.projects = ProjectManager(self)
-        self.runners = RunnerManager(self)
-        self.settings = ApplicationSettingsManager(self)
-        self.sidekiq = SidekiqManager(self)
-        self.snippets = SnippetManager(self)
-        self.users = UserManager(self)
-        self.teams = TeamManager(self)
-        self.todos = TodoManager(self)
-
-        # build the "submanagers"
-        for parent_cls in six.itervalues(globals()):
-            if (not inspect.isclass(parent_cls)
-               or not issubclass(parent_cls, GitlabObject)
-               or parent_cls == CurrentUser):
-                continue
-
-            if not parent_cls.managers:
-                continue
-
-            for var, cls, attrs in parent_cls.managers:
-                var_name = '%s_%s' % (self._cls_to_manager_prefix(parent_cls),
-                                      var)
-                manager = cls(self)
-                setattr(self, var_name, manager)
-
-    def _cls_to_manager_prefix(self, cls):
-        # Manage bad naming decisions
-        camel_case = (cls.__name__
-                      .replace('NotificationSettings', 'Notificationsettings')
-                      .replace('MergeRequest', 'Mergerequest')
-                      .replace('AccessRequest', 'Accessrequest'))
-        return re.sub(r'(.)([A-Z])', r'\1_\2', camel_case).lower()
-
-    @staticmethod
-    def from_config(gitlab_id=None, config_files=None):
-        """Create a Gitlab connection from configuration files.
-
-        Args:
-            gitlab_id (str): ID of the configuration section.
-            config_files list[str]: List of paths to configuration files.
-
-        Returns:
-            (gitlab.Gitlab): A Gitlab connection.
-
-        Raises:
-            gitlab.config.GitlabDataError: If the configuration is not correct.
-        """
-        config = gitlab.config.GitlabConfigParser(gitlab_id=gitlab_id,
-                                                  config_files=config_files)
-        return Gitlab(config.url, private_token=config.token,
-                      ssl_verify=config.ssl_verify, timeout=config.timeout,
-                      http_username=config.http_username,
-                      http_password=config.http_password)
-
-    def auth(self):
-        """Performs an authentication.
-
-        Uses either the private token, or the email/password pair.
-
-        The `user` attribute will hold a `gitlab.objects.CurrentUser` object on
-        success.
-        """
-        if self.private_token:
-            self.token_auth()
-        else:
-            self.credentials_auth()
-
-    def credentials_auth(self):
-        """Performs an authentication using email/password."""
-        if not self.email or not self.password:
-            raise GitlabAuthenticationError("Missing email/password")
-
-        data = json.dumps({'email': self.email, 'password': self.password})
-        r = self._raw_post('/session', data, content_type='application/json')
-        raise_error_from_response(r, GitlabAuthenticationError, 201)
-        self.user = CurrentUser(self, r.json())
-        """(gitlab.objects.CurrentUser): Object representing the user currently
-            logged.
-        """
-        self.set_token(self.user.private_token)
-
-    def version(self):
-        """Returns the version and revision of the gitlab server.
-
-        Note that self.version and self.revision will be set on the gitlab
-        object.
-
-        Returns:
-            tuple (str, str): The server version and server revision, or
-                              ('unknown', 'unknwown') if the server doesn't
-                              support this API call (gitlab < 8.13.0)
-        """
-        r = self._raw_get('/version')
-        try:
-            raise_error_from_response(r, GitlabGetError, 200)
-            data = r.json()
-            self.version, self.revision = data['version'], data['revision']
-        except GitlabGetError:
-            self.version = self.revision = 'unknown'
-
-        return self.version, self.revision
-
-    def token_auth(self):
-        """Performs an authentication using the private token."""
-        self.user = CurrentUser(self)
-
-    def set_url(self, url):
-        """Updates the GitLab URL.
-
-        Args:
-            url (str): Base URL of the GitLab server.
-        """
-        self._url = '%s/api/v3' % url
-
-    def _construct_url(self, id_, obj, parameters, action=None):
-        if 'next_url' in parameters:
-            return parameters['next_url']
-        args = _sanitize(parameters)
-
-        url_attr = '_url'
-        if action is not None:
-            attr = '_%s_url' % action
-            if hasattr(obj, attr):
-                url_attr = attr
-        obj_url = getattr(obj, url_attr)
-
-        # TODO(gpocentek): the following will need an update when we have
-        # object with both urlPlural and _ACTION_url attributes
-        if id_ is None and obj._urlPlural is not None:
-            url = obj._urlPlural % args
-        else:
-            url = obj_url % args
-
-        if id_ is not None:
-            return '%s/%s' % (url, str(id_))
-        else:
-            return url
-
-    def set_token(self, token):
-        """Sets the private token for authentication.
-
-        Args:
-            token (str): The private token.
-        """
-        self.private_token = token if token else None
-        if token:
-            self.headers["PRIVATE-TOKEN"] = token
-        elif "PRIVATE-TOKEN" in self.headers:
-            del self.headers["PRIVATE-TOKEN"]
-
-    def set_credentials(self, email, password):
-        """Sets the email/login and password for authentication.
-
-        Args:
-            email (str): The user email or login.
-            password (str): The user password.
-        """
-        self.email = email
-        self.password = password
-
-    def enable_debug(self):
-        import logging
-        try:
-            from http.client import HTTPConnection
-        except ImportError:
-            from httplib import HTTPConnection  # noqa
-
-        HTTPConnection.debuglevel = 1
-        logging.basicConfig()
-        logging.getLogger().setLevel(logging.DEBUG)
-        requests_log = logging.getLogger("requests.packages.urllib3")
-        requests_log.setLevel(logging.DEBUG)
-        requests_log.propagate = True
-
-    def _create_headers(self, content_type=None):
-        request_headers = self.headers.copy()
-        if content_type is not None:
-            request_headers['Content-type'] = content_type
-        return request_headers
-
-    def _create_auth(self):
-        if self.http_username and self.http_password:
-            return requests.auth.HTTPBasicAuth(self.http_username,
-                                               self.http_password)
-        return None
-
-    def _get_session_opts(self, content_type):
-        return {
-            'headers': self._create_headers(content_type),
-            'auth': self._create_auth(),
-            'timeout': self.timeout,
-            'verify': self.ssl_verify
-        }
-
-    def _raw_get(self, path_, content_type=None, streamed=False, **kwargs):
-        if path_.startswith('http://') or path_.startswith('https://'):
-            url = path_
-        else:
-            url = '%s%s' % (self._url, path_)
-
-        opts = self._get_session_opts(content_type)
-        try:
-            return self.session.get(url, params=kwargs, stream=streamed,
-                                    **opts)
-        except Exception as e:
-            raise GitlabConnectionError(
-                "Can't connect to GitLab server (%s)" % e)
-
-    def _raw_list(self, path_, cls, extra_attrs={}, **kwargs):
-        params = extra_attrs.copy()
-        params.update(kwargs.copy())
-
-        get_all_results = kwargs.get('all', False)
-
-        # Remove these keys to avoid breaking the listing (urls will get too
-        # long otherwise)
-        for key in ['all', 'next_url']:
-            if key in params:
-                del params[key]
-
-        r = self._raw_get(path_, **params)
-        raise_error_from_response(r, GitlabListError)
-
-        # These attributes are not needed in the object
-        for key in ['page', 'per_page', 'sudo']:
-            if key in params:
-                del params[key]
-
-        # Add _from_api manually, because we are not creating objects
-        # through normal path_
-        params['_from_api'] = True
-
-        results = [cls(self, item, **params) for item in r.json()
-                   if item is not None]
-        if ('next' in r.links and 'url' in r.links['next']
-           and get_all_results is True):
-            args = kwargs.copy()
-            args['next_url'] = r.links['next']['url']
-            results.extend(self.list(cls, **args))
-        return results
-
-    def _raw_post(self, path_, data=None, content_type=None, **kwargs):
-        url = '%s%s' % (self._url, path_)
-        opts = self._get_session_opts(content_type)
-        try:
-            return self.session.post(url, params=kwargs, data=data, **opts)
-        except Exception as e:
-            raise GitlabConnectionError(
-                "Can't connect to GitLab server (%s)" % e)
-
-    def _raw_put(self, path_, data=None, content_type=None, **kwargs):
-        url = '%s%s' % (self._url, path_)
-        opts = self._get_session_opts(content_type)
-        try:
-            return self.session.put(url, data=data, params=kwargs, **opts)
-        except Exception as e:
-            raise GitlabConnectionError(
-                "Can't connect to GitLab server (%s)" % e)
-
-    def _raw_delete(self, path_, content_type=None, **kwargs):
-        url = '%s%s' % (self._url, path_)
-        opts = self._get_session_opts(content_type)
-        try:
-            return self.session.delete(url, params=kwargs, **opts)
-        except Exception as e:
-            raise GitlabConnectionError(
-                "Can't connect to GitLab server (%s)" % e)
-
-    def list(self, obj_class, **kwargs):
-        """Request the listing of GitLab resources.
-
-        Args:
-            obj_class (object): The class of resource to request.
-            **kwargs: Additional arguments to send to GitLab.
-
-        Returns:
-            list(obj_class): A list of objects of class `obj_class`.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabListError: If the server fails to perform the request.
-        """
-        missing = []
-        for k in itertools.chain(obj_class.requiredUrlAttrs,
-                                 obj_class.requiredListAttrs):
-            if k not in kwargs:
-                missing.append(k)
-        if missing:
-            raise GitlabListError('Missing attribute(s): %s' %
-                                  ", ".join(missing))
-
-        url = self._construct_url(id_=None, obj=obj_class, parameters=kwargs)
-
-        return self._raw_list(url, obj_class, **kwargs)
-
-    def get(self, obj_class, id=None, **kwargs):
-        """Request a GitLab resources.
-
-        Args:
-            obj_class (object): The class of resource to request.
-            id: The object ID.
-            **kwargs: Additional arguments to send to GitLab.
-
-        Returns:
-            obj_class: An object of class `obj_class`.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabGetError: If the server fails to perform the request.
-        """
-        missing = []
-        for k in itertools.chain(obj_class.requiredUrlAttrs,
-                                 obj_class.requiredGetAttrs):
-            if k not in kwargs:
-                missing.append(k)
-        if missing:
-            raise GitlabGetError('Missing attribute(s): %s' %
-                                 ", ".join(missing))
-
-        url = self._construct_url(id_=_sanitize(id), obj=obj_class,
-                                  parameters=kwargs)
-
-        r = self._raw_get(url, **kwargs)
-        raise_error_from_response(r, GitlabGetError)
-        return r.json()
-
-    def delete(self, obj, id=None, **kwargs):
-        """Delete an object on the GitLab server.
-
-        Args:
-            obj (object or id): The object, or the class of the object to
-                delete. If it is the class, the id of the object must be
-                specified as the `id` arguments.
-            id: ID of the object to remove. Required if `obj` is a class.
-            **kwargs: Additional arguments to send to GitLab.
-
-        Returns:
-            bool: True if the operation succeeds.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabDeleteError: If the server fails to perform the request.
-        """
-        if inspect.isclass(obj):
-            if not issubclass(obj, GitlabObject):
-                raise GitlabError("Invalid class: %s" % obj)
-
-        params = {obj.idAttr: id if id else getattr(obj, obj.idAttr)}
-        params.update(kwargs)
-
-        missing = []
-        for k in itertools.chain(obj.requiredUrlAttrs,
-                                 obj.requiredDeleteAttrs):
-            if k not in params:
-                try:
-                    params[k] = getattr(obj, k)
-                except KeyError:
-                    missing.append(k)
-        if missing:
-            raise GitlabDeleteError('Missing attribute(s): %s' %
-                                    ", ".join(missing))
-
-        obj_id = params[obj.idAttr] if obj._id_in_delete_url else None
-        url = self._construct_url(id_=obj_id, obj=obj, parameters=params)
-
-        if obj._id_in_delete_url:
-            # The ID is already built, no need to add it as extra key in query
-            # string
-            params.pop(obj.idAttr)
-
-        r = self._raw_delete(url, **params)
-        raise_error_from_response(r, GitlabDeleteError,
-                                  expected_code=[200, 204])
-        return True
-
-    def create(self, obj, **kwargs):
-        """Create an object on the GitLab server.
-
-        The object class and attributes define the request to be made on the
-        GitLab server.
-
-        Args:
-            obj (object): The object to create.
-            **kwargs: Additional arguments to send to GitLab.
-
-        Returns:
-            str: A json representation of the object as returned by the GitLab
-                server
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabCreateError: If the server fails to perform the request.
-        """
-        params = obj.__dict__.copy()
-        params.update(kwargs)
-        missing = []
-        for k in itertools.chain(obj.requiredUrlAttrs,
-                                 obj.requiredCreateAttrs):
-            if k not in params:
-                missing.append(k)
-        if missing:
-            raise GitlabCreateError('Missing attribute(s): %s' %
-                                    ", ".join(missing))
-
-        url = self._construct_url(id_=None, obj=obj, parameters=params,
-                                  action='create')
-
-        # build data that can really be sent to server
-        data = obj._data_for_gitlab(extra_parameters=kwargs)
-
-        r = self._raw_post(url, data=data, content_type='application/json')
-        raise_error_from_response(r, GitlabCreateError, 201)
-        return r.json()
-
-    def update(self, obj, **kwargs):
-        """Update an object on the GitLab server.
-
-        The object class and attributes define the request to be made on the
-        GitLab server.
-
-        Args:
-            obj (object): The object to create.
-            **kwargs: Additional arguments to send to GitLab.
-
-        Returns:
-            str: A json representation of the object as returned by the GitLab
-                server
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabUpdateError: If the server fails to perform the request.
-        """
-        params = obj.__dict__.copy()
-        params.update(kwargs)
-        missing = []
-        if obj.requiredUpdateAttrs or obj.optionalUpdateAttrs:
-            required_attrs = obj.requiredUpdateAttrs
-        else:
-            required_attrs = obj.requiredCreateAttrs
-        for k in itertools.chain(obj.requiredUrlAttrs, required_attrs):
-            if k not in params:
-                missing.append(k)
-        if missing:
-            raise GitlabUpdateError('Missing attribute(s): %s' %
-                                    ", ".join(missing))
-        obj_id = params[obj.idAttr] if obj._id_in_update_url else None
-        url = self._construct_url(id_=obj_id, obj=obj, parameters=params)
-
-        # build data that can really be sent to server
-        data = obj._data_for_gitlab(extra_parameters=kwargs, update=True)
-
-        r = self._raw_put(url, data=data, content_type='application/json')
-        raise_error_from_response(r, GitlabUpdateError)
-        return r.json()
+import gitlab.config  # noqa: F401
+from gitlab._version import (  # noqa: F401
+    __author__,
+    __copyright__,
+    __email__,
+    __license__,
+    __title__,
+    __version__,
+)
+from gitlab.client import AsyncGraphQL, Gitlab, GitlabList, GraphQL  # noqa: F401
+from gitlab.exceptions import *  # noqa: F401,F403
+
+warnings.filterwarnings("default", category=DeprecationWarning, module="^gitlab")
+
+
+__all__ = [
+    "__author__",
+    "__copyright__",
+    "__email__",
+    "__license__",
+    "__title__",
+    "__version__",
+    "Gitlab",
+    "GitlabList",
+    "AsyncGraphQL",
+    "GraphQL",
+]
+__all__.extend(gitlab.exceptions.__all__)
diff --git a/gitlab/__main__.py b/gitlab/__main__.py
new file mode 100644
index 000000000..e1a914c6d
--- /dev/null
+++ b/gitlab/__main__.py
@@ -0,0 +1,4 @@
+import gitlab.cli
+
+if __name__ == "__main__":
+    gitlab.cli.main()
diff --git a/gitlab/_backends/__init__.py b/gitlab/_backends/__init__.py
new file mode 100644
index 000000000..7e6e36254
--- /dev/null
+++ b/gitlab/_backends/__init__.py
@@ -0,0 +1,22 @@
+"""
+Defines http backends for processing http requests
+"""
+
+from .requests_backend import (
+    JobTokenAuth,
+    OAuthTokenAuth,
+    PrivateTokenAuth,
+    RequestsBackend,
+    RequestsResponse,
+)
+
+DefaultBackend = RequestsBackend
+DefaultResponse = RequestsResponse
+
+__all__ = [
+    "DefaultBackend",
+    "DefaultResponse",
+    "JobTokenAuth",
+    "OAuthTokenAuth",
+    "PrivateTokenAuth",
+]
diff --git a/gitlab/_backends/graphql.py b/gitlab/_backends/graphql.py
new file mode 100644
index 000000000..5fe97de70
--- /dev/null
+++ b/gitlab/_backends/graphql.py
@@ -0,0 +1,44 @@
+from typing import Any
+
+import httpx
+from gql.transport.httpx import HTTPXAsyncTransport, HTTPXTransport
+
+
+class GitlabTransport(HTTPXTransport):
+    """A gql httpx transport that reuses an existing httpx.Client.
+    By default, gql's transports do not have a keep-alive session
+    and do not enable providing your own session that's kept open.
+    This transport lets us provide and close our session on our own
+    and provide additional auth.
+    For details, see https://github.com/graphql-python/gql/issues/91.
+    """
+
+    def __init__(self, *args: Any, client: httpx.Client, **kwargs: Any):
+        super().__init__(*args, **kwargs)
+        self.client = client
+
+    def connect(self) -> None:
+        pass
+
+    def close(self) -> None:
+        pass
+
+
+class GitlabAsyncTransport(HTTPXAsyncTransport):
+    """An async gql httpx transport that reuses an existing httpx.AsyncClient.
+    By default, gql's transports do not have a keep-alive session
+    and do not enable providing your own session that's kept open.
+    This transport lets us provide and close our session on our own
+    and provide additional auth.
+    For details, see https://github.com/graphql-python/gql/issues/91.
+    """
+
+    def __init__(self, *args: Any, client: httpx.AsyncClient, **kwargs: Any):
+        super().__init__(*args, **kwargs)
+        self.client = client
+
+    async def connect(self) -> None:
+        pass
+
+    async def close(self) -> None:
+        pass
diff --git a/gitlab/_backends/protocol.py b/gitlab/_backends/protocol.py
new file mode 100644
index 000000000..05721bc77
--- /dev/null
+++ b/gitlab/_backends/protocol.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+import abc
+from typing import Any, Protocol
+
+import requests
+from requests_toolbelt.multipart.encoder import MultipartEncoder  # type: ignore
+
+
+class BackendResponse(Protocol):
+    @abc.abstractmethod
+    def __init__(self, response: requests.Response) -> None: ...
+
+
+class Backend(Protocol):
+    @abc.abstractmethod
+    def http_request(
+        self,
+        method: str,
+        url: str,
+        json: dict[str, Any] | bytes | None,
+        data: dict[str, Any] | MultipartEncoder | None,
+        params: Any | None,
+        timeout: float | None,
+        verify: bool | str | None,
+        stream: bool | None,
+        **kwargs: Any,
+    ) -> BackendResponse: ...
diff --git a/gitlab/_backends/requests_backend.py b/gitlab/_backends/requests_backend.py
new file mode 100644
index 000000000..32b45ad9b
--- /dev/null
+++ b/gitlab/_backends/requests_backend.py
@@ -0,0 +1,168 @@
+from __future__ import annotations
+
+import dataclasses
+from typing import Any, BinaryIO, TYPE_CHECKING
+
+import requests
+from requests import PreparedRequest
+from requests.auth import AuthBase
+from requests.structures import CaseInsensitiveDict
+from requests_toolbelt.multipart.encoder import MultipartEncoder  # type: ignore
+
+from . import protocol
+
+
+class TokenAuth:
+    def __init__(self, token: str):
+        self.token = token
+
+
+class OAuthTokenAuth(TokenAuth, AuthBase):
+    def __call__(self, r: PreparedRequest) -> PreparedRequest:
+        r.headers["Authorization"] = f"Bearer {self.token}"
+        r.headers.pop("PRIVATE-TOKEN", None)
+        r.headers.pop("JOB-TOKEN", None)
+        return r
+
+
+class PrivateTokenAuth(TokenAuth, AuthBase):
+    def __call__(self, r: PreparedRequest) -> PreparedRequest:
+        r.headers["PRIVATE-TOKEN"] = self.token
+        r.headers.pop("JOB-TOKEN", None)
+        r.headers.pop("Authorization", None)
+        return r
+
+
+class JobTokenAuth(TokenAuth, AuthBase):
+    def __call__(self, r: PreparedRequest) -> PreparedRequest:
+        r.headers["JOB-TOKEN"] = self.token
+        r.headers.pop("PRIVATE-TOKEN", None)
+        r.headers.pop("Authorization", None)
+        return r
+
+
+@dataclasses.dataclass
+class SendData:
+    content_type: str
+    data: dict[str, Any] | MultipartEncoder | None = None
+    json: dict[str, Any] | bytes | None = None
+
+    def __post_init__(self) -> None:
+        if self.json is not None and self.data is not None:
+            raise ValueError(
+                f"`json` and `data` are mutually exclusive. Only one can be set. "
+                f"json={self.json!r}  data={self.data!r}"
+            )
+
+
+class RequestsResponse(protocol.BackendResponse):
+    def __init__(self, response: requests.Response) -> None:
+        self._response: requests.Response = response
+
+    @property
+    def response(self) -> requests.Response:
+        return self._response
+
+    @property
+    def status_code(self) -> int:
+        return self._response.status_code
+
+    @property
+    def headers(self) -> CaseInsensitiveDict[str]:
+        return self._response.headers
+
+    @property
+    def content(self) -> bytes:
+        return self._response.content
+
+    @property
+    def reason(self) -> str:
+        return self._response.reason
+
+    def json(self) -> Any:
+        return self._response.json()
+
+
+class RequestsBackend(protocol.Backend):
+    def __init__(self, session: requests.Session | None = None) -> None:
+        self._client: requests.Session = session or requests.Session()
+
+    @property
+    def client(self) -> requests.Session:
+        return self._client
+
+    @staticmethod
+    def prepare_send_data(
+        files: dict[str, Any] | None = None,
+        post_data: dict[str, Any] | bytes | BinaryIO | None = None,
+        raw: bool = False,
+    ) -> SendData:
+        if files:
+            if post_data is None:
+                post_data = {}
+            else:
+                # When creating a `MultipartEncoder` instance with data-types
+                # which don't have an `encode` method it will cause an error:
+                #       object has no attribute 'encode'
+                # So convert common non-string types into strings.
+                if TYPE_CHECKING:
+                    assert isinstance(post_data, dict)
+                for k, v in post_data.items():
+                    if isinstance(v, bool):
+                        v = int(v)
+                    if isinstance(v, (complex, float, int)):
+                        post_data[k] = str(v)
+            post_data["file"] = files.get("file")
+            post_data["avatar"] = files.get("avatar")
+
+            data = MultipartEncoder(fields=post_data)
+            return SendData(data=data, content_type=data.content_type)
+
+        if raw and post_data:
+            return SendData(data=post_data, content_type="application/octet-stream")
+
+        if TYPE_CHECKING:
+            assert not isinstance(post_data, BinaryIO)
+
+        return SendData(json=post_data, content_type="application/json")
+
+    def http_request(
+        self,
+        method: str,
+        url: str,
+        json: dict[str, Any] | bytes | None = None,
+        data: dict[str, Any] | MultipartEncoder | None = None,
+        params: Any | None = None,
+        timeout: float | None = None,
+        verify: bool | str | None = True,
+        stream: bool | None = False,
+        **kwargs: Any,
+    ) -> RequestsResponse:
+        """Make HTTP request
+
+        Args:
+            method: The HTTP method to call ('get', 'post', 'put', 'delete', etc.)
+            url: The full URL
+            data: The data to send to the server in the body of the request
+            json: Data to send in the body in json by default
+            timeout: The timeout, in seconds, for the request
+            verify: Whether SSL certificates should be validated. If
+                the value is a string, it is the path to a CA file used for
+                certificate validation.
+            stream: Whether the data should be streamed
+
+        Returns:
+            A requests Response object.
+        """
+        response: requests.Response = self._client.request(
+            method=method,
+            url=url,
+            params=params,
+            data=data,
+            timeout=timeout,
+            stream=stream,
+            verify=verify,
+            json=json,
+            **kwargs,
+        )
+        return RequestsResponse(response=response)
diff --git a/gitlab/_version.py b/gitlab/_version.py
new file mode 100644
index 000000000..24c1a84f8
--- /dev/null
+++ b/gitlab/_version.py
@@ -0,0 +1,6 @@
+__author__ = "Gauvain Pocentek, python-gitlab team"
+__copyright__ = "Copyright 2013-2019 Gauvain Pocentek, 2019-2023 python-gitlab team"
+__email__ = "gauvainpocentek@gmail.com"
+__license__ = "LGPL3"
+__title__ = "python-gitlab"
+__version__ = "6.1.0"
diff --git a/gitlab/base.py b/gitlab/base.py
new file mode 100644
index 000000000..1ee0051c9
--- /dev/null
+++ b/gitlab/base.py
@@ -0,0 +1,394 @@
+from __future__ import annotations
+
+import copy
+import importlib
+import json
+import pprint
+import textwrap
+from collections.abc import Iterable
+from types import ModuleType
+from typing import Any, ClassVar, Generic, TYPE_CHECKING, TypeVar
+
+import gitlab
+from gitlab import types as g_types
+from gitlab.exceptions import GitlabParsingError
+
+from .client import Gitlab, GitlabList
+
+__all__ = ["RESTObject", "RESTObjectList", "RESTManager"]
+
+
+_URL_ATTRIBUTE_ERROR = (
+    f"https://python-gitlab.readthedocs.io/en/v{gitlab.__version__}/"
+    f"faq.html#attribute-error-list"
+)
+
+
+class RESTObject:
+    """Represents an object built from server data.
+
+    It holds the attributes know from the server, and the updated attributes in
+    another. This allows smart updates, if the object allows it.
+
+    You can redefine ``_id_attr`` in child classes to specify which attribute
+    must be used as the unique ID. ``None`` means that the object can be updated
+    without ID in the url.
+
+    Likewise, you can define a ``_repr_attr`` in subclasses to specify which
+    attribute should be added as a human-readable identifier when called in the
+    object's ``__repr__()`` method.
+    """
+
+    _id_attr: str | None = "id"
+    _attrs: dict[str, Any]
+    _created_from_list: bool  # Indicates if object was created from a list() action
+    _module: ModuleType
+    _parent_attrs: dict[str, Any]
+    _repr_attr: str | None = None
+    _updated_attrs: dict[str, Any]
+    _lazy: bool
+    manager: RESTManager[Any]
+
+    def __init__(
+        self,
+        manager: RESTManager[Any],
+        attrs: dict[str, Any],
+        *,
+        created_from_list: bool = False,
+        lazy: bool = False,
+    ) -> None:
+        if not isinstance(attrs, dict):
+            raise GitlabParsingError(
+                f"Attempted to initialize RESTObject with a non-dictionary value: "
+                f"{attrs!r}\nThis likely indicates an incorrect or malformed server "
+                f"response."
+            )
+        self.__dict__.update(
+            {
+                "manager": manager,
+                "_attrs": attrs,
+                "_updated_attrs": {},
+                "_module": importlib.import_module(self.__module__),
+                "_created_from_list": created_from_list,
+                "_lazy": lazy,
+            }
+        )
+        self.__dict__["_parent_attrs"] = self.manager.parent_attrs
+        self._create_managers()
+
+    def __getstate__(self) -> dict[str, Any]:
+        state = self.__dict__.copy()
+        module = state.pop("_module")
+        state["_module_name"] = module.__name__
+        return state
+
+    def __setstate__(self, state: dict[str, Any]) -> None:
+        module_name = state.pop("_module_name")
+        self.__dict__.update(state)
+        self.__dict__["_module"] = importlib.import_module(module_name)
+
+    def __getattr__(self, name: str) -> Any:
+        if name in self.__dict__["_updated_attrs"]:
+            return self.__dict__["_updated_attrs"][name]
+
+        if name in self.__dict__["_attrs"]:
+            value = self.__dict__["_attrs"][name]
+            # If the value is a list, we copy it in the _updated_attrs dict
+            # because we are not able to detect changes made on the object
+            # (append, insert, pop, ...). Without forcing the attr
+            # creation __setattr__ is never called, the list never ends up
+            # in the _updated_attrs dict, and the update() and save()
+            # method never push the new data to the server.
+            # See https://github.com/python-gitlab/python-gitlab/issues/306
+            #
+            # note: _parent_attrs will only store simple values (int) so we
+            # don't make this check in the next block.
+            if isinstance(value, list):
+                self.__dict__["_updated_attrs"][name] = value[:]
+                return self.__dict__["_updated_attrs"][name]
+
+            return value
+
+        if name in self.__dict__["_parent_attrs"]:
+            return self.__dict__["_parent_attrs"][name]
+
+        message = f"{type(self).__name__!r} object has no attribute {name!r}"
+        if self._created_from_list:
+            message = (
+                f"{message}\n\n"
+                + textwrap.fill(
+                    f"{self.__class__!r} was created via a list() call and "
+                    f"only a subset of the data may be present. To ensure "
+                    f"all data is present get the object using a "
+                    f"get(object.id) call. For more details, see:"
+                )
+                + f"\n\n{_URL_ATTRIBUTE_ERROR}"
+            )
+        elif self._lazy:
+            message = f"{message}\n\n" + textwrap.fill(
+                f"If you tried to access object attributes returned from the server, "
+                f"note that {self.__class__!r} was created as a `lazy` object and was "
+                f"not initialized with any data."
+            )
+        raise AttributeError(message)
+
+    def __setattr__(self, name: str, value: Any) -> None:
+        self.__dict__["_updated_attrs"][name] = value
+
+    def asdict(self, *, with_parent_attrs: bool = False) -> dict[str, Any]:
+        data = {}
+        if with_parent_attrs:
+            data.update(copy.deepcopy(self._parent_attrs))
+        data.update(copy.deepcopy(self._attrs))
+        data.update(copy.deepcopy(self._updated_attrs))
+        return data
+
+    @property
+    def attributes(self) -> dict[str, Any]:
+        return self.asdict(with_parent_attrs=True)
+
+    def to_json(self, *, with_parent_attrs: bool = False, **kwargs: Any) -> str:
+        return json.dumps(self.asdict(with_parent_attrs=with_parent_attrs), **kwargs)
+
+    def __str__(self) -> str:
+        return f"{type(self)} => {self.asdict()}"
+
+    def pformat(self) -> str:
+        return f"{type(self)} => \n{pprint.pformat(self.asdict())}"
+
+    def pprint(self) -> None:
+        print(self.pformat())
+
+    def __repr__(self) -> str:
+        name = self.__class__.__name__
+
+        if (self._id_attr and self._repr_value) and (self._id_attr != self._repr_attr):
+            return (
+                f"<{name} {self._id_attr}:{self.get_id()} "
+                f"{self._repr_attr}:{self._repr_value}>"
+            )
+        if self._id_attr:
+            return f"<{name} {self._id_attr}:{self.get_id()}>"
+        if self._repr_value:
+            return f"<{name} {self._repr_attr}:{self._repr_value}>"
+
+        return f"<{name}>"
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, RESTObject):
+            return NotImplemented
+        if self.get_id() and other.get_id():
+            return self.get_id() == other.get_id()
+        return super() == other
+
+    def __ne__(self, other: object) -> bool:
+        if not isinstance(other, RESTObject):
+            return NotImplemented
+        if self.get_id() and other.get_id():
+            return self.get_id() != other.get_id()
+        return super() != other
+
+    def __dir__(self) -> Iterable[str]:
+        return set(self.attributes).union(super().__dir__())
+
+    def __hash__(self) -> int:
+        if not self.get_id():
+            return super().__hash__()
+        return hash(self.get_id())
+
+    def _create_managers(self) -> None:
+        # NOTE(jlvillal): We are creating our managers by looking at the class
+        # annotations. If an attribute is annotated as being a *Manager type
+        # then we create the manager and assign it to the attribute.
+        for attr, annotation in sorted(self.__class__.__annotations__.items()):
+            # We ignore creating a manager for the 'manager' attribute as that
+            # is done in the self.__init__() method
+            if attr in ("manager",):
+                continue
+            if not isinstance(annotation, (type, str)):  # pragma: no cover
+                continue
+            if isinstance(annotation, type):
+                cls_name = annotation.__name__
+            else:
+                cls_name = annotation
+            # All *Manager classes are used except for the base "RESTManager" class
+            if cls_name == "RESTManager" or not cls_name.endswith("Manager"):
+                continue
+            cls = getattr(self._module, cls_name)
+            manager = cls(self.manager.gitlab, parent=self)
+            # Since we have our own __setattr__ method, we can't use setattr()
+            self.__dict__[attr] = manager
+
+    def _update_attrs(self, new_attrs: dict[str, Any]) -> None:
+        self.__dict__["_updated_attrs"] = {}
+        self.__dict__["_attrs"] = new_attrs
+
+    def get_id(self) -> int | str | None:
+        """Returns the id of the resource."""
+        if self._id_attr is None or not hasattr(self, self._id_attr):
+            return None
+        id_val = getattr(self, self._id_attr)
+        if TYPE_CHECKING:
+            assert id_val is None or isinstance(id_val, (int, str))
+        return id_val
+
+    @property
+    def _repr_value(self) -> str | None:
+        """Safely returns the human-readable resource name if present."""
+        if self._repr_attr is None or not hasattr(self, self._repr_attr):
+            return None
+        repr_val = getattr(self, self._repr_attr)
+        if TYPE_CHECKING:
+            assert isinstance(repr_val, str)
+        return repr_val
+
+    @property
+    def encoded_id(self) -> int | str | None:
+        """Ensure that the ID is url-encoded so that it can be safely used in a URL
+        path"""
+        obj_id = self.get_id()
+        if isinstance(obj_id, str):
+            obj_id = gitlab.utils.EncodedId(obj_id)
+        return obj_id
+
+
+TObjCls = TypeVar("TObjCls", bound=RESTObject)
+
+
+class RESTObjectList(Generic[TObjCls]):
+    """Generator object representing a list of RESTObject's.
+
+    This generator uses the Gitlab pagination system to fetch new data when
+    required.
+
+    Note: you should not instantiate such objects, they are returned by calls
+    to RESTManager.list()
+
+    Args:
+        manager: Manager to attach to the created objects
+        obj_cls: Type of objects to create from the json data
+        _list: A GitlabList object
+    """
+
+    def __init__(
+        self, manager: RESTManager[TObjCls], obj_cls: type[TObjCls], _list: GitlabList
+    ) -> None:
+        """Creates an objects list from a GitlabList.
+
+        You should not create objects of this type, but use managers list()
+        methods instead.
+
+        Args:
+            manager: the RESTManager to attach to the objects
+            obj_cls: the class of the created objects
+            _list: the GitlabList holding the data
+        """
+        self.manager = manager
+        self._obj_cls = obj_cls
+        self._list = _list
+
+    def __iter__(self) -> RESTObjectList[TObjCls]:
+        return self
+
+    def __len__(self) -> int:
+        return len(self._list)
+
+    def __next__(self) -> TObjCls:
+        return self.next()
+
+    def next(self) -> TObjCls:
+        data = self._list.next()
+        return self._obj_cls(self.manager, data, created_from_list=True)
+
+    @property
+    def current_page(self) -> int:
+        """The current page number."""
+        return self._list.current_page
+
+    @property
+    def prev_page(self) -> int | None:
+        """The previous page number.
+
+        If None, the current page is the first.
+        """
+        return self._list.prev_page
+
+    @property
+    def next_page(self) -> int | None:
+        """The next page number.
+
+        If None, the current page is the last.
+        """
+        return self._list.next_page
+
+    @property
+    def per_page(self) -> int | None:
+        """The number of items per page."""
+        return self._list.per_page
+
+    @property
+    def total_pages(self) -> int | None:
+        """The total number of pages."""
+        return self._list.total_pages
+
+    @property
+    def total(self) -> int | None:
+        """The total number of items."""
+        return self._list.total
+
+
+class RESTManager(Generic[TObjCls]):
+    """Base class for CRUD operations on objects.
+
+    Derived class must define ``_path`` and ``_obj_cls``.
+
+    ``_path``: Base URL path on which requests will be sent (e.g. '/projects')
+    ``_obj_cls``: The class of objects that will be created
+    """
+
+    _create_attrs: g_types.RequiredOptional = g_types.RequiredOptional()
+    _update_attrs: g_types.RequiredOptional = g_types.RequiredOptional()
+    _path: ClassVar[str]
+    _obj_cls: type[TObjCls]
+    _from_parent_attrs: dict[str, Any] = {}
+    _types: dict[str, type[g_types.GitlabAttribute]] = {}
+
+    _computed_path: str
+    _parent: RESTObject | None
+    _parent_attrs: dict[str, Any]
+    gitlab: Gitlab
+
+    def __init__(self, gl: Gitlab, parent: RESTObject | None = None) -> None:
+        """REST manager constructor.
+
+        Args:
+            gl: :class:`~gitlab.Gitlab` connection to use to make requests.
+            parent: REST object to which the manager is attached.
+        """
+        self.gitlab = gl
+        self._parent = parent  # for nested managers
+        self._computed_path = self._compute_path()
+
+    @property
+    def parent_attrs(self) -> dict[str, Any] | None:
+        return self._parent_attrs
+
+    def _compute_path(self, path: str | None = None) -> str:
+        self._parent_attrs = {}
+        if path is None:
+            path = self._path
+        if self._parent is None or not self._from_parent_attrs:
+            return path
+
+        data: dict[str, gitlab.utils.EncodedId | None] = {}
+        for self_attr, parent_attr in self._from_parent_attrs.items():
+            if not hasattr(self._parent, parent_attr):
+                data[self_attr] = None
+                continue
+            data[self_attr] = gitlab.utils.EncodedId(getattr(self._parent, parent_attr))
+        self._parent_attrs = data
+        return path.format(**data)
+
+    @property
+    def path(self) -> str:
+        return self._computed_path
diff --git a/gitlab/cli.py b/gitlab/cli.py
index 32b3ec850..ca4734190 100644
--- a/gitlab/cli.py
+++ b/gitlab/cli.py
@@ -1,556 +1,410 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-#
-# Copyright (C) 2013-2015 Gauvain Pocentek <gauvain@pocentek.net>
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Lesser General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Lesser General Public License for more details.
-#
-# You should have received a copy of the GNU Lesser General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+from __future__ import annotations
 
-from __future__ import print_function
-from __future__ import division
-from __future__ import absolute_import
 import argparse
-import inspect
-import operator
+import dataclasses
+import functools
+import os
+import pathlib
 import re
 import sys
+from types import ModuleType
+from typing import Any, Callable, cast, NoReturn, TYPE_CHECKING, TypeVar
 
-import six
-
-import gitlab
-
-camel_re = re.compile('(.)([A-Z])')
-
-EXTRA_ACTIONS = {
-    gitlab.Group: {'search': {'required': ['query']}},
-    gitlab.ProjectBranch: {'protect': {'required': ['id', 'project-id']},
-                           'unprotect': {'required': ['id', 'project-id']}},
-    gitlab.ProjectBuild: {'cancel': {'required': ['id', 'project-id']},
-                          'retry': {'required': ['id', 'project-id']},
-                          'artifacts': {'required': ['id', 'project-id']},
-                          'trace': {'required': ['id', 'project-id']}},
-    gitlab.ProjectCommit: {'diff': {'required': ['id', 'project-id']},
-                           'blob': {'required': ['id', 'project-id',
-                                                 'filepath']},
-                           'builds': {'required': ['id', 'project-id']}},
-    gitlab.ProjectIssue: {'subscribe': {'required': ['id', 'project-id']},
-                          'unsubscribe': {'required': ['id', 'project-id']},
-                          'move': {'required': ['id', 'project-id',
-                                                'to-project-id']}},
-    gitlab.ProjectMergeRequest: {
-        'closes-issues': {'required': ['id', 'project-id']},
-        'cancel': {'required': ['id', 'project-id']},
-        'merge': {'required': ['id', 'project-id'],
-                  'optional': ['merge-commit-message',
-                               'should-remove-source-branch',
-                               'merged-when-build-succeeds']}
-    },
-    gitlab.ProjectMilestone: {'issues': {'required': ['id', 'project-id']}},
-    gitlab.Project: {'search': {'required': ['query']},
-                     'owned': {},
-                     'all': {'optional': [('all', bool)]},
-                     'starred': {},
-                     'star': {'required': ['id']},
-                     'unstar': {'required': ['id']},
-                     'archive': {'required': ['id']},
-                     'unarchive': {'required': ['id']},
-                     'share': {'required': ['id', 'group-id',
-                                            'group-access']}},
-    gitlab.User: {'block': {'required': ['id']},
-                  'unblock': {'required': ['id']},
-                  'search': {'required': ['query']},
-                  'get-by-username': {'required': ['query']}},
-}
-
-
-def _die(msg, e=None):
-    if e:
-        msg = "%s (%s)" % (msg, e)
-    sys.stderr.write(msg + "\n")
-    sys.exit(1)
-
-
-def _what_to_cls(what):
-    return "".join([s.capitalize() for s in what.split("-")])
-
+from requests.structures import CaseInsensitiveDict
 
-def _cls_to_what(cls):
-    return camel_re.sub(r'\1-\2', cls.__name__).lower()
+import gitlab.config
+from gitlab.base import RESTObject
 
+# This regex is based on:
+# https://github.com/jpvanhal/inflection/blob/master/inflection/__init__.py
+camel_upperlower_regex = re.compile(r"([A-Z]+)([A-Z][a-z])")
+camel_lowerupper_regex = re.compile(r"([a-z\d])([A-Z])")
 
-def do_auth(gitlab_id, config_files):
-    try:
-        gl = gitlab.Gitlab.from_config(gitlab_id, config_files)
-        gl.auth()
-        return gl
-    except Exception as e:
-        _die(str(e))
 
+@dataclasses.dataclass
+class CustomAction:
+    required: tuple[str, ...]
+    optional: tuple[str, ...]
+    in_object: bool
+    requires_id: bool  # if the `_id_attr` value should be a required argument
+    help: str | None  # help text for the custom action
 
-class GitlabCLI(object):
-    def _get_id(self, cls, args):
-        try:
-            id = args.pop(cls.idAttr)
-        except Exception:
-            _die("Missing --%s argument" % cls.idAttr.replace('_', '-'))
 
-        return id
+# custom_actions = {
+#    cls: {
+#        action: CustomAction,
+#    },
+# }
+custom_actions: dict[str, dict[str, CustomAction]] = {}
 
-    def do_create(self, cls, gl, what, args):
-        if not cls.canCreate:
-            _die("%s objects can't be created" % what)
-
-        try:
-            o = cls.create(gl, args)
-        except Exception as e:
-            _die("Impossible to create object", e)
-
-        return o
-
-    def do_list(self, cls, gl, what, args):
-        if not cls.canList:
-            _die("%s objects can't be listed" % what)
-
-        try:
-            l = cls.list(gl, **args)
-        except Exception as e:
-            _die("Impossible to list objects", e)
-
-        return l
-
-    def do_get(self, cls, gl, what, args):
-        if cls.canGet is False:
-            _die("%s objects can't be retrieved" % what)
-
-        id = None
-        if cls not in [gitlab.CurrentUser] and cls.getRequiresId:
-            id = self._get_id(cls, args)
-
-        try:
-            o = cls.get(gl, id, **args)
-        except Exception as e:
-            _die("Impossible to get object", e)
-
-        return o
-
-    def do_delete(self, cls, gl, what, args):
-        if not cls.canDelete:
-            _die("%s objects can't be deleted" % what)
-
-        id = args.pop(cls.idAttr)
-        try:
-            gl.delete(cls, id, **args)
-        except Exception as e:
-            _die("Impossible to destroy object", e)
-
-    def do_update(self, cls, gl, what, args):
-        if not cls.canUpdate:
-            _die("%s objects can't be updated" % what)
-
-        o = self.do_get(cls, gl, what, args)
-        try:
-            for k, v in args.items():
-                o.__dict__[k] = v
-            o.save()
-        except Exception as e:
-            _die("Impossible to update object", e)
-
-        return o
-
-    def do_group_search(self, cls, gl, what, args):
-        try:
-            return gl.groups.search(args['query'])
-        except Exception as e:
-            _die("Impossible to search projects", e)
-
-    def do_project_search(self, cls, gl, what, args):
-        try:
-            return gl.projects.search(args['query'])
-        except Exception as e:
-            _die("Impossible to search projects", e)
-
-    def do_project_all(self, cls, gl, what, args):
-        try:
-            return gl.projects.all(all=args.get('all', False))
-        except Exception as e:
-            _die("Impossible to list all projects", e)
-
-    def do_project_starred(self, cls, gl, what, args):
-        try:
-            return gl.projects.starred()
-        except Exception as e:
-            _die("Impossible to list starred projects", e)
-
-    def do_project_owned(self, cls, gl, what, args):
-        try:
-            return gl.projects.owned()
-        except Exception as e:
-            _die("Impossible to list owned projects", e)
-
-    def do_project_star(self, cls, gl, what, args):
-        try:
-            o = self.do_get(cls, gl, what, args)
-            o.star()
-        except Exception as e:
-            _die("Impossible to star project", e)
-
-    def do_project_unstar(self, cls, gl, what, args):
-        try:
-            o = self.do_get(cls, gl, what, args)
-            o.unstar()
-        except Exception as e:
-            _die("Impossible to unstar project", e)
 
-    def do_project_archive(self, cls, gl, what, args):
-        try:
-            o = self.do_get(cls, gl, what, args)
-            o.archive_()
-        except Exception as e:
-            _die("Impossible to archive project", e)
-
-    def do_project_unarchive(self, cls, gl, what, args):
-        try:
-            o = self.do_get(cls, gl, what, args)
-            o.unarchive_()
-        except Exception as e:
-            _die("Impossible to unarchive project", e)
-
-    def do_project_share(self, cls, gl, what, args):
-        try:
-            o = self.do_get(cls, gl, what, args)
-            o.share(args['group_id'], args['group_access'])
-        except Exception as e:
-            _die("Impossible to share project", e)
-
-    def do_user_block(self, cls, gl, what, args):
-        try:
-            o = self.do_get(cls, gl, what, args)
-            o.block()
-        except Exception as e:
-            _die("Impossible to block user", e)
-
-    def do_user_unblock(self, cls, gl, what, args):
-        try:
-            o = self.do_get(cls, gl, what, args)
-            o.unblock()
-        except Exception as e:
-            _die("Impossible to block user", e)
-
-    def do_project_commit_diff(self, cls, gl, what, args):
-        try:
-            o = self.do_get(cls, gl, what, args)
-            return [x['diff'] for x in o.diff()]
-        except Exception as e:
-            _die("Impossible to get commit diff", e)
-
-    def do_project_commit_blob(self, cls, gl, what, args):
-        try:
-            o = self.do_get(cls, gl, what, args)
-            return o.blob(args['filepath'])
-        except Exception as e:
-            _die("Impossible to get commit blob", e)
-
-    def do_project_commit_builds(self, cls, gl, what, args):
-        try:
-            o = self.do_get(cls, gl, what, args)
-            return o.builds()
-        except Exception as e:
-            _die("Impossible to get commit builds", e)
-
-    def do_project_build_cancel(self, cls, gl, what, args):
-        try:
-            o = self.do_get(cls, gl, what, args)
-            return o.cancel()
-        except Exception as e:
-            _die("Impossible to cancel project build", e)
+# For an explanation of how these type-hints work see:
+# https://mypy.readthedocs.io/en/stable/generics.html#declaring-decorators
+#
+# The goal here is that functions which get decorated will retain their types.
+__F = TypeVar("__F", bound=Callable[..., Any])
+
+
+def register_custom_action(
+    *,
+    cls_names: str | tuple[str, ...],
+    required: tuple[str, ...] = (),
+    optional: tuple[str, ...] = (),
+    custom_action: str | None = None,
+    requires_id: bool = True,  # if the `_id_attr` value should be a required argument
+    help: str | None = None,  # help text for the action
+) -> Callable[[__F], __F]:
+    def wrap(f: __F) -> __F:
+        @functools.wraps(f)
+        def wrapped_f(*args: Any, **kwargs: Any) -> Any:
+            return f(*args, **kwargs)
+
+        # in_obj defines whether the method belongs to the obj or the manager
+        in_obj = True
+        if isinstance(cls_names, tuple):
+            classes = cls_names
+        else:
+            classes = (cls_names,)
+
+        for cls_name in classes:
+            final_name = cls_name
+            if cls_name.endswith("Manager"):
+                final_name = cls_name.replace("Manager", "")
+                in_obj = False
+            if final_name not in custom_actions:
+                custom_actions[final_name] = {}
+
+            action = custom_action or f.__name__.replace("_", "-")
+            custom_actions[final_name][action] = CustomAction(
+                required=required,
+                optional=optional,
+                in_object=in_obj,
+                requires_id=requires_id,
+                help=help,
+            )
+
+        return cast(__F, wrapped_f)
+
+    return wrap
+
+
+def die(msg: str, e: Exception | None = None) -> NoReturn:
+    if e:
+        msg = f"{msg} ({e})"
+    sys.stderr.write(f"{msg}\n")
+    sys.exit(1)
 
-    def do_project_build_retry(self, cls, gl, what, args):
-        try:
-            o = self.do_get(cls, gl, what, args)
-            return o.retry()
-        except Exception as e:
-            _die("Impossible to retry project build", e)
 
-    def do_project_build_artifacts(self, cls, gl, what, args):
-        try:
-            o = self.do_get(cls, gl, what, args)
-            return o.artifacts()
-        except Exception as e:
-            _die("Impossible to get project build artifacts", e)
+def gitlab_resource_to_cls(
+    gitlab_resource: str, namespace: ModuleType
+) -> type[RESTObject]:
+    classes = CaseInsensitiveDict(namespace.__dict__)
+    lowercase_class = gitlab_resource.replace("-", "")
+    class_type = classes[lowercase_class]
+    if TYPE_CHECKING:
+        assert isinstance(class_type, type)
+        assert issubclass(class_type, RESTObject)
+    return class_type
 
-    def do_project_build_trace(self, cls, gl, what, args):
-        try:
-            o = self.do_get(cls, gl, what, args)
-            return o.trace()
-        except Exception as e:
-            _die("Impossible to get project build trace", e)
 
-    def do_project_issue_subscribe(self, cls, gl, what, args):
-        try:
-            o = self.do_get(cls, gl, what, args)
-            o.subscribe()
-        except Exception as e:
-            _die("Impossible to subscribe to issue", e)
+def cls_to_gitlab_resource(cls: type[RESTObject]) -> str:
+    dasherized_uppercase = camel_upperlower_regex.sub(r"\1-\2", cls.__name__)
+    dasherized_lowercase = camel_lowerupper_regex.sub(r"\1-\2", dasherized_uppercase)
+    return dasherized_lowercase.lower()
 
-    def do_project_issue_unsubscribe(self, cls, gl, what, args):
-        try:
-            o = self.do_get(cls, gl, what, args)
-            o.unsubscribe()
-        except Exception as e:
-            _die("Impossible to subscribe to issue", e)
 
-    def do_project_issue_move(self, cls, gl, what, args):
-        try:
-            o = self.do_get(cls, gl, what, args)
-            o.move(args['to_project_id'])
-        except Exception as e:
-            _die("Impossible to move issue", e)
+def _get_base_parser(add_help: bool = True) -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(
+        add_help=add_help,
+        description="GitLab API Command Line Interface",
+        allow_abbrev=False,
+    )
+    parser.add_argument("--version", help="Display the version.", action="store_true")
+    parser.add_argument(
+        "-v",
+        "--verbose",
+        "--fancy",
+        help="Verbose mode (legacy format only) [env var: GITLAB_VERBOSE]",
+        action="store_true",
+        default=os.getenv("GITLAB_VERBOSE"),
+    )
+    parser.add_argument(
+        "-d",
+        "--debug",
+        help="Debug mode (display HTTP requests) [env var: GITLAB_DEBUG]",
+        action="store_true",
+        default=os.getenv("GITLAB_DEBUG"),
+    )
+    parser.add_argument(
+        "-c",
+        "--config-file",
+        action="append",
+        help=(
+            "Configuration file to use. Can be used multiple times. "
+            "[env var: PYTHON_GITLAB_CFG]"
+        ),
+    )
+    parser.add_argument(
+        "-g",
+        "--gitlab",
+        help=(
+            "Which configuration section should "
+            "be used. If not defined, the default selection "
+            "will be used."
+        ),
+        required=False,
+    )
+    parser.add_argument(
+        "-o",
+        "--output",
+        help="Output format (v4 only): json|legacy|yaml",
+        required=False,
+        choices=["json", "legacy", "yaml"],
+        default="legacy",
+    )
+    parser.add_argument(
+        "-f",
+        "--fields",
+        help=(
+            "Fields to display in the output (comma "
+            "separated). Not used with legacy output"
+        ),
+        required=False,
+    )
+    parser.add_argument(
+        "--server-url",
+        help=("GitLab server URL [env var: GITLAB_URL]"),
+        required=False,
+        default=os.getenv("GITLAB_URL"),
+    )
+
+    ssl_verify_group = parser.add_mutually_exclusive_group()
+    ssl_verify_group.add_argument(
+        "--ssl-verify",
+        help=(
+            "Path to a CA_BUNDLE file or directory with certificates of trusted CAs. "
+            "[env var: GITLAB_SSL_VERIFY]"
+        ),
+        required=False,
+        default=os.getenv("GITLAB_SSL_VERIFY"),
+    )
+    ssl_verify_group.add_argument(
+        "--no-ssl-verify",
+        help="Disable SSL verification",
+        required=False,
+        dest="ssl_verify",
+        action="store_false",
+    )
+
+    parser.add_argument(
+        "--timeout",
+        help=(
+            "Timeout to use for requests to the GitLab server. "
+            "[env var: GITLAB_TIMEOUT]"
+        ),
+        required=False,
+        type=int,
+        default=os.getenv("GITLAB_TIMEOUT"),
+    )
+    parser.add_argument(
+        "--api-version",
+        help=("GitLab API version [env var: GITLAB_API_VERSION]"),
+        required=False,
+        default=os.getenv("GITLAB_API_VERSION"),
+    )
+    parser.add_argument(
+        "--per-page",
+        help=(
+            "Number of entries to return per page in the response. "
+            "[env var: GITLAB_PER_PAGE]"
+        ),
+        required=False,
+        type=int,
+        default=os.getenv("GITLAB_PER_PAGE"),
+    )
+    parser.add_argument(
+        "--pagination",
+        help=(
+            "Whether to use keyset or offset pagination [env var: GITLAB_PAGINATION]"
+        ),
+        required=False,
+        default=os.getenv("GITLAB_PAGINATION"),
+    )
+    parser.add_argument(
+        "--order-by",
+        help=("Set order_by globally [env var: GITLAB_ORDER_BY]"),
+        required=False,
+        default=os.getenv("GITLAB_ORDER_BY"),
+    )
+    parser.add_argument(
+        "--user-agent",
+        help=(
+            "The user agent to send to GitLab with the HTTP request. "
+            "[env var: GITLAB_USER_AGENT]"
+        ),
+        required=False,
+        default=os.getenv("GITLAB_USER_AGENT"),
+    )
+
+    tokens = parser.add_mutually_exclusive_group()
+    tokens.add_argument(
+        "--private-token",
+        help=("GitLab private access token [env var: GITLAB_PRIVATE_TOKEN]"),
+        required=False,
+        default=os.getenv("GITLAB_PRIVATE_TOKEN"),
+    )
+    tokens.add_argument(
+        "--oauth-token",
+        help=("GitLab OAuth token [env var: GITLAB_OAUTH_TOKEN]"),
+        required=False,
+        default=os.getenv("GITLAB_OAUTH_TOKEN"),
+    )
+    tokens.add_argument(
+        "--job-token",
+        help=("GitLab CI job token [env var: CI_JOB_TOKEN]"),
+        required=False,
+    )
+    parser.add_argument(
+        "--skip-login",
+        help=(
+            "Skip initial authenticated API call to the current user endpoint. "
+            "This may  be useful when invoking the CLI in scripts. "
+            "[env var: GITLAB_SKIP_LOGIN]"
+        ),
+        action="store_true",
+        default=os.getenv("GITLAB_SKIP_LOGIN"),
+    )
+    parser.add_argument(
+        "--no-mask-credentials",
+        help="Don't mask credentials in debug mode",
+        dest="mask_credentials",
+        action="store_false",
+    )
+    return parser
 
-    def do_project_merge_request_closesissues(self, cls, gl, what, args):
-        try:
-            o = self.do_get(cls, gl, what, args)
-            return o.closes_issues()
-        except Exception as e:
-            _die("Impossible to list issues closed by merge request", e)
 
-    def do_project_merge_request_cancel(self, cls, gl, what, args):
-        try:
-            o = self.do_get(cls, gl, what, args)
-            return o.cancel_merge_when_build_succeeds()
-        except Exception as e:
-            _die("Impossible to cancel merge request", e)
+def _get_parser() -> argparse.ArgumentParser:
+    # NOTE: We must delay import of gitlab.v4.cli until now or
+    # otherwise it will cause circular import errors
+    from gitlab.v4 import cli as v4_cli
 
-    def do_project_merge_request_merge(self, cls, gl, what, args):
-        try:
-            o = self.do_get(cls, gl, what, args)
-            should_remove = args.get('should_remove_source_branch', False)
-            build_succeeds = args.get('merged_when_build_succeeds', False)
-            return o.merge(
-                merge_commit_message=args.get('merge_commit_message', ''),
-                should_remove_source_branch=should_remove,
-                merged_when_build_succeeds=build_succeeds)
-        except Exception as e:
-            _die("Impossible to validate merge request", e)
-
-    def do_project_milestone_issues(self, cls, gl, what, args):
-        try:
-            o = self.do_get(cls, gl, what, args)
-            return o.issues()
-        except Exception as e:
-            _die("Impossible to get milestone issues", e)
+    parser = _get_base_parser()
+    return v4_cli.extend_parser(parser)
 
-    def do_user_search(self, cls, gl, what, args):
-        try:
-            return gl.users.search(args['query'])
-        except Exception as e:
-            _die("Impossible to search users", e)
 
-    def do_user_getbyusername(self, cls, gl, what, args):
+def _parse_value(v: Any) -> Any:
+    if isinstance(v, str) and v.startswith("@@"):
+        return v[1:]
+    if isinstance(v, str) and v.startswith("@"):
+        # If the user-provided value starts with @, we try to read the file
+        # path provided after @ as the real value.
+        filepath = pathlib.Path(v[1:]).expanduser().resolve()
         try:
-            return gl.users.search(args['query'])
-        except Exception as e:
-            _die("Impossible to get user %s" % args['query'], e)
-
-
-def _populate_sub_parser_by_class(cls, sub_parser):
-    for action_name in ['list', 'get', 'create', 'update', 'delete']:
-        attr = 'can' + action_name.capitalize()
-        if not getattr(cls, attr):
-            continue
-        sub_parser_action = sub_parser.add_parser(action_name)
-        [sub_parser_action.add_argument("--%s" % x.replace('_', '-'),
-                                        required=True)
-         for x in cls.requiredUrlAttrs]
-        sub_parser_action.add_argument("--sudo", required=False)
-
-        if action_name == "list":
-            [sub_parser_action.add_argument("--%s" % x.replace('_', '-'),
-                                            required=True)
-             for x in cls.requiredListAttrs]
-            sub_parser_action.add_argument("--page", required=False)
-            sub_parser_action.add_argument("--per-page", required=False)
-            sub_parser_action.add_argument("--all", required=False,
-                                           action='store_true')
-
-        if action_name in ["get", "delete"]:
-            if cls not in [gitlab.CurrentUser]:
-                if cls.getRequiresId:
-                    id_attr = cls.idAttr.replace('_', '-')
-                    sub_parser_action.add_argument("--%s" % id_attr,
-                                                   required=True)
-                [sub_parser_action.add_argument("--%s" % x.replace('_', '-'),
-                                                required=True)
-                 for x in cls.requiredGetAttrs if x != cls.idAttr]
-
-        if action_name == "get":
-            [sub_parser_action.add_argument("--%s" % x.replace('_', '-'),
-                                            required=False)
-             for x in cls.optionalGetAttrs]
-
-        if action_name == "list":
-            [sub_parser_action.add_argument("--%s" % x.replace('_', '-'),
-                                            required=False)
-             for x in cls.optionalListAttrs]
-
-        if action_name == "create":
-            [sub_parser_action.add_argument("--%s" % x.replace('_', '-'),
-                                            required=True)
-             for x in cls.requiredCreateAttrs]
-            [sub_parser_action.add_argument("--%s" % x.replace('_', '-'),
-                                            required=False)
-             for x in cls.optionalCreateAttrs]
-
-        if action_name == "update":
-            id_attr = cls.idAttr.replace('_', '-')
-            sub_parser_action.add_argument("--%s" % id_attr,
-                                           required=True)
-
-            attrs = (cls.requiredUpdateAttrs
-                     if (cls.requiredUpdateAttrs or cls.optionalUpdateAttrs)
-                     else cls.requiredCreateAttrs)
-            [sub_parser_action.add_argument("--%s" % x.replace('_', '-'),
-                                            required=True)
-             for x in attrs if x != cls.idAttr]
-
-            attrs = (cls.optionalUpdateAttrs
-                     if (cls.requiredUpdateAttrs or cls.optionalUpdateAttrs)
-                     else cls.optionalCreateAttrs)
-            [sub_parser_action.add_argument("--%s" % x.replace('_', '-'),
-                                            required=False)
-             for x in attrs]
-
-    if cls in EXTRA_ACTIONS:
-        def _add_arg(parser, required, data):
-            extra_args = {}
-            if isinstance(data, tuple):
-                if data[1] is bool:
-                    extra_args = {'action': 'store_true'}
-                data = data[0]
-
-            parser.add_argument("--%s" % data, required=required, **extra_args)
-
-        for action_name in sorted(EXTRA_ACTIONS[cls]):
-            sub_parser_action = sub_parser.add_parser(action_name)
-            d = EXTRA_ACTIONS[cls][action_name]
-            [_add_arg(sub_parser_action, True, arg)
-             for arg in d.get('required', [])]
-            [_add_arg(sub_parser_action, False, arg)
-             for arg in d.get('optional', [])]
-
-
-def _build_parser(args=sys.argv[1:]):
-    parser = argparse.ArgumentParser(
-        description="GitLab API Command Line Interface")
-    parser.add_argument("--version", help="Display the version.",
-                        action="store_true")
-    parser.add_argument("-v", "--verbose", "--fancy",
-                        help="Verbose mode",
-                        action="store_true")
-    parser.add_argument("-c", "--config-file", action='append',
-                        help=("Configuration file to use. Can be used "
-                              "multiple times."))
-    parser.add_argument("-g", "--gitlab",
-                        help=("Which configuration section should "
-                              "be used. If not defined, the default selection "
-                              "will be used."),
-                        required=False)
-
-    subparsers = parser.add_subparsers(title='object', dest='what',
-                                       help="Object to manipulate.")
-    subparsers.required = True
-
-    # populate argparse for all Gitlab Object
-    classes = []
-    for cls in gitlab.__dict__.values():
-        try:
-            if gitlab.GitlabObject in inspect.getmro(cls):
-                classes.append(cls)
-        except AttributeError:
-            pass
-    classes.sort(key=operator.attrgetter("__name__"))
-
-    for cls in classes:
-        arg_name = _cls_to_what(cls)
-        object_group = subparsers.add_parser(arg_name)
+            with open(filepath, encoding="utf-8") as f:
+                return f.read()
+        except UnicodeDecodeError:
+            with open(filepath, "rb") as f:
+                return f.read()
+        except OSError as exc:
+            exc_name = type(exc).__name__
+            sys.stderr.write(f"{exc_name}: {exc}\n")
+            sys.exit(1)
 
-        object_subparsers = object_group.add_subparsers(
-            dest='action', help="Action to execute.")
-        _populate_sub_parser_by_class(cls, object_subparsers)
-        object_subparsers.required = True
+    return v
 
-    return parser
 
+def docs() -> argparse.ArgumentParser:  # pragma: no cover
+    """
+    Provide a statically generated parser for sphinx only, so we don't need
+    to provide dummy gitlab config for readthedocs.
+    """
+    if "sphinx" not in sys.modules:
+        sys.exit("Docs parser is only intended for build_sphinx")
 
-def _parse_args(args=sys.argv[1:]):
-    parser = _build_parser()
-    return parser.parse_args(args)
+    return _get_parser()
 
 
-def main():
+def main() -> None:
     if "--version" in sys.argv:
         print(gitlab.__version__)
-        exit(0)
+        sys.exit(0)
 
-    arg = _parse_args()
-    args = arg.__dict__
+    parser = _get_base_parser(add_help=False)
 
-    config_files = arg.config_file
-    gitlab_id = arg.gitlab
-    verbose = arg.verbose
-    action = arg.action
-    what = arg.what
+    # This first parsing step is used to find the gitlab config to use, and
+    # load the propermodule (v3 or v4) accordingly. At that point we don't have
+    # any subparser setup
+    (options, _) = parser.parse_known_args(sys.argv)
+    try:
+        config = gitlab.config.GitlabConfigParser(options.gitlab, options.config_file)
+    except gitlab.config.ConfigError as e:
+        if "--help" in sys.argv or "-h" in sys.argv:
+            parser.print_help()
+            sys.exit(0)
+        sys.exit(str(e))
+    # We only support v4 API at this time
+    if config.api_version not in ("4",):  # dead code # pragma: no cover
+        raise ModuleNotFoundError(f"gitlab.v{config.api_version}.cli")
+
+    # Now we build the entire set of subcommands and do the complete parsing
+    parser = _get_parser()
+    try:
+        import argcomplete  # type: ignore
 
+        argcomplete.autocomplete(parser)  # pragma: no cover
+    except Exception:
+        pass
+    args = parser.parse_args()
+
+    config_files = args.config_file
+    gitlab_id = args.gitlab
+    verbose = args.verbose
+    output = args.output
+    fields = []
+    if args.fields:
+        fields = [x.strip() for x in args.fields.split(",")]
+    debug = args.debug
+    gitlab_resource = args.gitlab_resource
+    resource_action = args.resource_action
+    skip_login = args.skip_login
+    mask_credentials = args.mask_credentials
+
+    args_dict = vars(args)
     # Remove CLI behavior-related args
-    for item in ("gitlab", "config_file", "verbose", "what", "action",
-                 "version"):
-        args.pop(item)
-
-    args = {k: v for k, v in args.items() if v is not None}
+    for item in (
+        "api_version",
+        "config_file",
+        "debug",
+        "fields",
+        "gitlab",
+        "gitlab_resource",
+        "job_token",
+        "mask_credentials",
+        "oauth_token",
+        "output",
+        "pagination",
+        "private_token",
+        "resource_action",
+        "server_url",
+        "skip_login",
+        "ssl_verify",
+        "timeout",
+        "user_agent",
+        "verbose",
+        "version",
+    ):
+        args_dict.pop(item)
+    args_dict = {k: _parse_value(v) for k, v in args_dict.items() if v is not None}
 
-    cls = None
     try:
-        cls = gitlab.__dict__[_what_to_cls(what)]
-    except Exception:
-        _die("Unknown object: %s" % what)
-
-    gl = do_auth(gitlab_id, config_files)
-
-    cli = GitlabCLI()
-    method = None
-    what = what.replace('-', '_')
-    action = action.lower().replace('-', '')
-    for test in ["do_%s_%s" % (what, action),
-                 "do_%s" % action]:
-        if hasattr(cli, test):
-            method = test
-            break
-
-    if method is None:
-        sys.stderr.write("Don't know how to deal with this!\n")
-        sys.exit(1)
-
-    ret_val = getattr(cli, method)(cls, gl, what, args)
-
-    if isinstance(ret_val, list):
-        for o in ret_val:
-            if isinstance(o, gitlab.GitlabObject):
-                o.display(verbose)
-                print("")
-            else:
-                print(o)
-    elif isinstance(ret_val, gitlab.GitlabObject):
-        ret_val.display(verbose)
-    elif isinstance(ret_val, six.string_types):
-        print(ret_val)
-
-    sys.exit(0)
+        gl = gitlab.Gitlab.merge_config(vars(options), gitlab_id, config_files)
+        if debug:
+            gl.enable_debug(mask_credentials=mask_credentials)
+        if not skip_login and (gl.private_token or gl.oauth_token):
+            gl.auth()
+    except Exception as e:
+        die(str(e))
+
+    gitlab.v4.cli.run(
+        gl, gitlab_resource, resource_action, args_dict, verbose, output, fields
+    )
diff --git a/gitlab/client.py b/gitlab/client.py
new file mode 100644
index 000000000..37dd4c2e6
--- /dev/null
+++ b/gitlab/client.py
@@ -0,0 +1,1453 @@
+"""Wrapper for the GitLab API."""
+
+from __future__ import annotations
+
+import os
+import re
+from typing import Any, BinaryIO, cast, TYPE_CHECKING
+from urllib import parse
+
+import requests
+
+import gitlab
+import gitlab.config
+import gitlab.const
+import gitlab.exceptions
+from gitlab import _backends, utils
+
+try:
+    import gql
+    import gql.transport.exceptions
+    import graphql
+    import httpx
+
+    from ._backends.graphql import GitlabAsyncTransport, GitlabTransport
+
+    _GQL_INSTALLED = True
+except ImportError:  # pragma: no cover
+    _GQL_INSTALLED = False
+
+
+REDIRECT_MSG = (
+    "python-gitlab detected a {status_code} ({reason!r}) redirection. You must update "
+    "your GitLab URL to the correct URL to avoid issues. The redirection was from: "
+    "{source!r} to {target!r}"
+)
+
+
+# https://docs.gitlab.com/ee/api/#offset-based-pagination
+_PAGINATION_URL = (
+    f"https://python-gitlab.readthedocs.io/en/v{gitlab.__version__}/"
+    f"api-usage.html#pagination"
+)
+
+
+class Gitlab:
+    """Represents a GitLab server connection.
+
+    Args:
+        url: The URL of the GitLab server (defaults to https://gitlab.com).
+        private_token: The user private token
+        oauth_token: An oauth token
+        job_token: A CI job token
+        ssl_verify: Whether SSL certificates should be validated. If
+            the value is a string, it is the path to a CA file used for
+            certificate validation.
+        timeout: Timeout to use for requests to the GitLab server.
+        http_username: Username for HTTP authentication
+        http_password: Password for HTTP authentication
+        api_version: Gitlab API version to use (support for 4 only)
+        pagination: Can be set to 'keyset' to use keyset pagination
+        order_by: Set order_by globally
+        user_agent: A custom user agent to use for making HTTP requests.
+        retry_transient_errors: Whether to retry after 500, 502, 503, 504
+            or 52x responses. Defaults to False.
+        keep_base_url: keep user-provided base URL for pagination if it
+            differs from response headers
+
+    Keyword Args:
+        requests.Session session: HTTP Requests Session
+        RequestsBackend backend: Backend that will be used to make http requests
+    """
+
+    def __init__(
+        self,
+        url: str | None = None,
+        private_token: str | None = None,
+        oauth_token: str | None = None,
+        job_token: str | None = None,
+        ssl_verify: bool | str = True,
+        http_username: str | None = None,
+        http_password: str | None = None,
+        timeout: float | None = None,
+        api_version: str = "4",
+        per_page: int | None = None,
+        pagination: str | None = None,
+        order_by: str | None = None,
+        user_agent: str = gitlab.const.USER_AGENT,
+        retry_transient_errors: bool = False,
+        keep_base_url: bool = False,
+        **kwargs: Any,
+    ) -> None:
+        self._api_version = str(api_version)
+        self._server_version: str | None = None
+        self._server_revision: str | None = None
+        self._base_url = utils.get_base_url(url)
+        self._url = f"{self._base_url}/api/v{api_version}"
+        #: Timeout to use for requests to gitlab server
+        self.timeout = timeout
+        self.retry_transient_errors = retry_transient_errors
+        self.keep_base_url = keep_base_url
+        #: Headers that will be used in request to GitLab
+        self.headers = {"User-Agent": user_agent}
+
+        #: Whether SSL certificates should be validated
+        self.ssl_verify = ssl_verify
+
+        self.private_token = private_token
+        self.http_username = http_username
+        self.http_password = http_password
+        self.oauth_token = oauth_token
+        self.job_token = job_token
+        self._set_auth_info()
+
+        #: Create a session object for requests
+        _backend: type[_backends.DefaultBackend] = kwargs.pop(
+            "backend", _backends.DefaultBackend
+        )
+        self._backend = _backend(**kwargs)
+        self.session = self._backend.client
+
+        self.per_page = per_page
+        self.pagination = pagination
+        self.order_by = order_by
+
+        # We only support v4 API at this time
+        if self._api_version not in ("4",):
+            raise ModuleNotFoundError(f"gitlab.v{self._api_version}.objects")
+        # NOTE: We must delay import of gitlab.v4.objects until now or
+        # otherwise it will cause circular import errors
+        from gitlab.v4 import objects
+
+        self._objects = objects
+        self.user: objects.CurrentUser | None = None
+
+        self.broadcastmessages = objects.BroadcastMessageManager(self)
+        """See :class:`~gitlab.v4.objects.BroadcastMessageManager`"""
+        self.bulk_imports = objects.BulkImportManager(self)
+        """See :class:`~gitlab.v4.objects.BulkImportManager`"""
+        self.bulk_import_entities = objects.BulkImportAllEntityManager(self)
+        """See :class:`~gitlab.v4.objects.BulkImportAllEntityManager`"""
+        self.ci_lint = objects.CiLintManager(self)
+        """See :class:`~gitlab.v4.objects.CiLintManager`"""
+        self.deploykeys = objects.DeployKeyManager(self)
+        """See :class:`~gitlab.v4.objects.DeployKeyManager`"""
+        self.deploytokens = objects.DeployTokenManager(self)
+        """See :class:`~gitlab.v4.objects.DeployTokenManager`"""
+        self.geonodes = objects.GeoNodeManager(self)
+        """See :class:`~gitlab.v4.objects.GeoNodeManager`"""
+        self.gitlabciymls = objects.GitlabciymlManager(self)
+        """See :class:`~gitlab.v4.objects.GitlabciymlManager`"""
+        self.gitignores = objects.GitignoreManager(self)
+        """See :class:`~gitlab.v4.objects.GitignoreManager`"""
+        self.groups = objects.GroupManager(self)
+        """See :class:`~gitlab.v4.objects.GroupManager`"""
+        self.hooks = objects.HookManager(self)
+        """See :class:`~gitlab.v4.objects.HookManager`"""
+        self.issues = objects.IssueManager(self)
+        """See :class:`~gitlab.v4.objects.IssueManager`"""
+        self.issues_statistics = objects.IssuesStatisticsManager(self)
+        """See :class:`~gitlab.v4.objects.IssuesStatisticsManager`"""
+        self.keys = objects.KeyManager(self)
+        """See :class:`~gitlab.v4.objects.KeyManager`"""
+        self.ldapgroups = objects.LDAPGroupManager(self)
+        """See :class:`~gitlab.v4.objects.LDAPGroupManager`"""
+        self.licenses = objects.LicenseManager(self)
+        """See :class:`~gitlab.v4.objects.LicenseManager`"""
+        self.namespaces = objects.NamespaceManager(self)
+        """See :class:`~gitlab.v4.objects.NamespaceManager`"""
+        self.member_roles = objects.MemberRoleManager(self)
+        """See :class:`~gitlab.v4.objects.MergeRequestManager`"""
+        self.mergerequests = objects.MergeRequestManager(self)
+        """See :class:`~gitlab.v4.objects.MergeRequestManager`"""
+        self.notificationsettings = objects.NotificationSettingsManager(self)
+        """See :class:`~gitlab.v4.objects.NotificationSettingsManager`"""
+        self.projects = objects.ProjectManager(self)
+        """See :class:`~gitlab.v4.objects.ProjectManager`"""
+        self.registry_repositories = objects.RegistryRepositoryManager(self)
+        """See :class:`~gitlab.v4.objects.RegistryRepositoryManager`"""
+        self.runners = objects.RunnerManager(self)
+        """See :class:`~gitlab.v4.objects.RunnerManager`"""
+        self.runners_all = objects.RunnerAllManager(self)
+        """See :class:`~gitlab.v4.objects.RunnerManager`"""
+        self.settings = objects.ApplicationSettingsManager(self)
+        """See :class:`~gitlab.v4.objects.ApplicationSettingsManager`"""
+        self.appearance = objects.ApplicationAppearanceManager(self)
+        """See :class:`~gitlab.v4.objects.ApplicationAppearanceManager`"""
+        self.sidekiq = objects.SidekiqManager(self)
+        """See :class:`~gitlab.v4.objects.SidekiqManager`"""
+        self.snippets = objects.SnippetManager(self)
+        """See :class:`~gitlab.v4.objects.SnippetManager`"""
+        self.users = objects.UserManager(self)
+        """See :class:`~gitlab.v4.objects.UserManager`"""
+        self.todos = objects.TodoManager(self)
+        """See :class:`~gitlab.v4.objects.TodoManager`"""
+        self.dockerfiles = objects.DockerfileManager(self)
+        """See :class:`~gitlab.v4.objects.DockerfileManager`"""
+        self.events = objects.EventManager(self)
+        """See :class:`~gitlab.v4.objects.EventManager`"""
+        self.audit_events = objects.AuditEventManager(self)
+        """See :class:`~gitlab.v4.objects.AuditEventManager`"""
+        self.features = objects.FeatureManager(self)
+        """See :class:`~gitlab.v4.objects.FeatureManager`"""
+        self.pagesdomains = objects.PagesDomainManager(self)
+        """See :class:`~gitlab.v4.objects.PagesDomainManager`"""
+        self.user_activities = objects.UserActivitiesManager(self)
+        """See :class:`~gitlab.v4.objects.UserActivitiesManager`"""
+        self.applications = objects.ApplicationManager(self)
+        """See :class:`~gitlab.v4.objects.ApplicationManager`"""
+        self.variables = objects.VariableManager(self)
+        """See :class:`~gitlab.v4.objects.VariableManager`"""
+        self.personal_access_tokens = objects.PersonalAccessTokenManager(self)
+        """See :class:`~gitlab.v4.objects.PersonalAccessTokenManager`"""
+        self.topics = objects.TopicManager(self)
+        """See :class:`~gitlab.v4.objects.TopicManager`"""
+        self.statistics = objects.ApplicationStatisticsManager(self)
+        """See :class:`~gitlab.v4.objects.ApplicationStatisticsManager`"""
+
+    def __enter__(self) -> Gitlab:
+        return self
+
+    def __exit__(self, *args: Any) -> None:
+        self.session.close()
+
+    def __getstate__(self) -> dict[str, Any]:
+        state = self.__dict__.copy()
+        state.pop("_objects")
+        return state
+
+    def __setstate__(self, state: dict[str, Any]) -> None:
+        self.__dict__.update(state)
+        # We only support v4 API at this time
+        if self._api_version not in ("4",):
+            raise ModuleNotFoundError(
+                f"gitlab.v{self._api_version}.objects"
+            )  # pragma: no cover, dead code currently
+        # NOTE: We must delay import of gitlab.v4.objects until now or
+        # otherwise it will cause circular import errors
+        from gitlab.v4 import objects
+
+        self._objects = objects
+
+    @property
+    def url(self) -> str:
+        """The user-provided server URL."""
+        return self._base_url
+
+    @property
+    def api_url(self) -> str:
+        """The computed API base URL."""
+        return self._url
+
+    @property
+    def api_version(self) -> str:
+        """The API version used (4 only)."""
+        return self._api_version
+
+    @classmethod
+    def from_config(
+        cls,
+        gitlab_id: str | None = None,
+        config_files: list[str] | None = None,
+        **kwargs: Any,
+    ) -> Gitlab:
+        """Create a Gitlab connection from configuration files.
+
+        Args:
+            gitlab_id: ID of the configuration section.
+            config_files list[str]: List of paths to configuration files.
+
+        kwargs:
+            session requests.Session: Custom requests Session
+
+        Returns:
+            A Gitlab connection.
+
+        Raises:
+            gitlab.config.GitlabDataError: If the configuration is not correct.
+        """
+        config = gitlab.config.GitlabConfigParser(
+            gitlab_id=gitlab_id, config_files=config_files
+        )
+        return cls(
+            config.url,
+            private_token=config.private_token,
+            oauth_token=config.oauth_token,
+            job_token=config.job_token,
+            ssl_verify=config.ssl_verify,
+            timeout=config.timeout,
+            http_username=config.http_username,
+            http_password=config.http_password,
+            api_version=config.api_version,
+            per_page=config.per_page,
+            pagination=config.pagination,
+            order_by=config.order_by,
+            user_agent=config.user_agent,
+            retry_transient_errors=config.retry_transient_errors,
+            keep_base_url=config.keep_base_url,
+            **kwargs,
+        )
+
+    @classmethod
+    def merge_config(
+        cls,
+        options: dict[str, Any],
+        gitlab_id: str | None = None,
+        config_files: list[str] | None = None,
+    ) -> Gitlab:
+        """Create a Gitlab connection by merging configuration with
+        the following precedence:
+
+        1. Explicitly provided CLI arguments,
+        2. Environment variables,
+        3. Configuration files:
+            a. explicitly defined config files:
+                i. via the `--config-file` CLI argument,
+                ii. via the `PYTHON_GITLAB_CFG` environment variable,
+            b. user-specific config file,
+            c. system-level config file,
+        4. Environment variables always present in CI (CI_SERVER_URL, CI_JOB_TOKEN).
+
+        Args:
+            options: A dictionary of explicitly provided key-value options.
+            gitlab_id: ID of the configuration section.
+            config_files: List of paths to configuration files.
+        Returns:
+            (gitlab.Gitlab): A Gitlab connection.
+
+        Raises:
+            gitlab.config.GitlabDataError: If the configuration is not correct.
+        """
+        config = gitlab.config.GitlabConfigParser(
+            gitlab_id=gitlab_id, config_files=config_files
+        )
+        url = (
+            options.get("server_url")
+            or config.url
+            or os.getenv("CI_SERVER_URL")
+            or gitlab.const.DEFAULT_URL
+        )
+        private_token, oauth_token, job_token = cls._merge_auth(options, config)
+
+        return cls(
+            url=url,
+            private_token=private_token,
+            oauth_token=oauth_token,
+            job_token=job_token,
+            ssl_verify=options.get("ssl_verify") or config.ssl_verify,
+            timeout=options.get("timeout") or config.timeout,
+            api_version=options.get("api_version") or config.api_version,
+            per_page=options.get("per_page") or config.per_page,
+            pagination=options.get("pagination") or config.pagination,
+            order_by=options.get("order_by") or config.order_by,
+            user_agent=options.get("user_agent") or config.user_agent,
+        )
+
+    @staticmethod
+    def _merge_auth(
+        options: dict[str, Any], config: gitlab.config.GitlabConfigParser
+    ) -> tuple[str | None, str | None, str | None]:
+        """
+        Return a tuple where at most one of 3 token types ever has a value.
+        Since multiple types of tokens may be present in the environment,
+        options, or config files, this precedence ensures we don't
+        inadvertently cause errors when initializing the client.
+
+        This is especially relevant when executed in CI where user and
+        CI-provided values are both available.
+        """
+        private_token = options.get("private_token") or config.private_token
+        oauth_token = options.get("oauth_token") or config.oauth_token
+        job_token = (
+            options.get("job_token") or config.job_token or os.getenv("CI_JOB_TOKEN")
+        )
+
+        if private_token:
+            return (private_token, None, None)
+        if oauth_token:
+            return (None, oauth_token, None)
+        if job_token:
+            return (None, None, job_token)
+
+        return (None, None, None)
+
+    def auth(self) -> None:
+        """Performs an authentication using private token. Warns the user if a
+        potentially misconfigured URL is detected on the client or server side.
+
+        The `user` attribute will hold a `gitlab.objects.CurrentUser` object on
+        success.
+        """
+        self.user = self._objects.CurrentUserManager(self).get()
+
+        if hasattr(self.user, "web_url") and hasattr(self.user, "username"):
+            self._check_url(self.user.web_url, path=self.user.username)
+
+    def version(self) -> tuple[str, str]:
+        """Returns the version and revision of the gitlab server.
+
+        Note that self.version and self.revision will be set on the gitlab
+        object.
+
+        Returns:
+            The server version and server revision.
+                ('unknown', 'unknown') if the server doesn't perform as expected.
+        """
+        if self._server_version is None:
+            try:
+                data = self.http_get("/version")
+                if isinstance(data, dict):
+                    self._server_version = data["version"]
+                    self._server_revision = data["revision"]
+                else:
+                    self._server_version = "unknown"
+                    self._server_revision = "unknown"
+            except Exception:
+                self._server_version = "unknown"
+                self._server_revision = "unknown"
+
+        return cast(str, self._server_version), cast(str, self._server_revision)
+
+    @gitlab.exceptions.on_http_error(gitlab.exceptions.GitlabMarkdownError)
+    def markdown(
+        self, text: str, gfm: bool = False, project: str | None = None, **kwargs: Any
+    ) -> str:
+        """Render an arbitrary Markdown document.
+
+        Args:
+            text: The markdown text to render
+            gfm: Render text using GitLab Flavored Markdown. Default is False
+            project: Full path of a project used a context when `gfm` is True
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabMarkdownError: If the server cannot perform the request
+
+        Returns:
+            The HTML rendering of the markdown text.
+        """
+        post_data = {"text": text, "gfm": gfm}
+        if project is not None:
+            post_data["project"] = project
+        data = self.http_post("/markdown", post_data=post_data, **kwargs)
+        if TYPE_CHECKING:
+            assert not isinstance(data, requests.Response)
+            assert isinstance(data["html"], str)
+        return data["html"]
+
+    @gitlab.exceptions.on_http_error(gitlab.exceptions.GitlabLicenseError)
+    def get_license(self, **kwargs: Any) -> dict[str, str | dict[str, str]]:
+        """Retrieve information about the current license.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabGetError: If the server cannot perform the request
+
+        Returns:
+            The current license information
+        """
+        result = self.http_get("/license", **kwargs)
+        if isinstance(result, dict):
+            return result
+        return {}
+
+    @gitlab.exceptions.on_http_error(gitlab.exceptions.GitlabLicenseError)
+    def set_license(self, license: str, **kwargs: Any) -> dict[str, Any]:
+        """Add a new license.
+
+        Args:
+            license: The license string
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabPostError: If the server cannot perform the request
+
+        Returns:
+            The new license information
+        """
+        data = {"license": license}
+        result = self.http_post("/license", post_data=data, **kwargs)
+        if TYPE_CHECKING:
+            assert not isinstance(result, requests.Response)
+        return result
+
+    def _set_auth_info(self) -> None:
+        tokens = [
+            token
+            for token in [self.private_token, self.oauth_token, self.job_token]
+            if token
+        ]
+        if len(tokens) > 1:
+            raise ValueError(
+                "Only one of private_token, oauth_token or job_token should "
+                "be defined"
+            )
+        if (self.http_username and not self.http_password) or (
+            not self.http_username and self.http_password
+        ):
+            raise ValueError("Both http_username and http_password should be defined")
+        if tokens and self.http_username:
+            raise ValueError(
+                "Only one of token authentications or http "
+                "authentication should be defined"
+            )
+
+        self._auth: requests.auth.AuthBase | None = None
+        if self.private_token:
+            self._auth = _backends.PrivateTokenAuth(self.private_token)
+
+        if self.oauth_token:
+            self._auth = _backends.OAuthTokenAuth(self.oauth_token)
+
+        if self.job_token:
+            self._auth = _backends.JobTokenAuth(self.job_token)
+
+        if self.http_username and self.http_password:
+            self._auth = requests.auth.HTTPBasicAuth(
+                self.http_username, self.http_password
+            )
+
+    def enable_debug(self, mask_credentials: bool = True) -> None:
+        import logging
+        from http import client
+
+        client.HTTPConnection.debuglevel = 1
+        logging.basicConfig()
+        logger = logging.getLogger()
+        logger.setLevel(logging.DEBUG)
+
+        httpclient_log = logging.getLogger("http.client")
+        httpclient_log.propagate = True
+        httpclient_log.setLevel(logging.DEBUG)
+
+        requests_log = logging.getLogger("requests.packages.urllib3")
+        requests_log.setLevel(logging.DEBUG)
+        requests_log.propagate = True
+
+        # shadow http.client prints to log()
+        # https://stackoverflow.com/a/16337639
+        def print_as_log(*args: Any) -> None:
+            httpclient_log.log(logging.DEBUG, " ".join(args))
+
+        setattr(client, "print", print_as_log)
+
+        if not mask_credentials:
+            return
+
+        token = self.private_token or self.oauth_token or self.job_token
+        handler = logging.StreamHandler()
+        handler.setFormatter(utils.MaskingFormatter(masked=token))
+        logger.handlers.clear()
+        logger.addHandler(handler)
+
+    def _get_session_opts(self) -> dict[str, Any]:
+        return {
+            "headers": self.headers.copy(),
+            "auth": self._auth,
+            "timeout": self.timeout,
+            "verify": self.ssl_verify,
+        }
+
+    def _build_url(self, path: str) -> str:
+        """Returns the full url from path.
+
+        If path is already a url, return it unchanged. If it's a path, append
+        it to the stored url.
+
+        Returns:
+            The full URL
+        """
+        if path.startswith("http://") or path.startswith("https://"):
+            return path
+        return f"{self._url}{path}"
+
+    def _check_url(self, url: str | None, *, path: str = "api") -> str | None:
+        """
+        Checks if ``url`` starts with a different base URL from the user-provided base
+        URL and warns the user before returning it. If ``keep_base_url`` is set to
+        ``True``, instead returns the URL massaged to match the user-provided base URL.
+        """
+        if not url or url.startswith(self.url):
+            return url
+
+        match = re.match(rf"(^.*?)/{path}", url)
+        if not match:
+            return url
+
+        base_url = match.group(1)
+        if self.keep_base_url:
+            return url.replace(base_url, f"{self._base_url}")
+
+        utils.warn(
+            message=(
+                f"The base URL in the server response differs from the user-provided "
+                f"base URL ({self.url} -> {base_url}).\nThis is usually caused by a "
+                f"misconfigured base URL on your side or a misconfigured external_url "
+                f"on the server side, and can lead to broken pagination and unexpected "
+                f"behavior. If this is intentional, use `keep_base_url=True` when "
+                f"initializing the Gitlab instance to keep the user-provided base URL."
+            ),
+            category=UserWarning,
+        )
+        return url
+
+    @staticmethod
+    def _check_redirects(result: requests.Response) -> None:
+        # Check the requests history to detect 301/302 redirections.
+        # If the initial verb is POST or PUT, the redirected request will use a
+        # GET request, leading to unwanted behaviour.
+        # If we detect a redirection with a POST or a PUT request, we
+        # raise an exception with a useful error message.
+        if not result.history:
+            return
+
+        for item in result.history:
+            if item.status_code not in (301, 302):
+                continue
+            # GET and HEAD methods can be redirected without issue
+            if item.request.method in ("GET", "HEAD"):
+                continue
+            target = item.headers.get("location")
+            raise gitlab.exceptions.RedirectError(
+                REDIRECT_MSG.format(
+                    status_code=item.status_code,
+                    reason=item.reason,
+                    source=item.url,
+                    target=target,
+                )
+            )
+
+    def http_request(
+        self,
+        verb: str,
+        path: str,
+        query_data: dict[str, Any] | None = None,
+        post_data: dict[str, Any] | bytes | BinaryIO | None = None,
+        raw: bool = False,
+        streamed: bool = False,
+        files: dict[str, Any] | None = None,
+        timeout: float | None = None,
+        obey_rate_limit: bool = True,
+        retry_transient_errors: bool | None = None,
+        max_retries: int = 10,
+        extra_headers: dict[str, Any] | None = None,
+        **kwargs: Any,
+    ) -> requests.Response:
+        """Make an HTTP request to the Gitlab server.
+
+        Args:
+            verb: The HTTP method to call ('get', 'post', 'put', 'delete')
+            path: Path or full URL to query ('/projects' or
+                        'http://whatever/v4/api/projecs')
+            query_data: Data to send as query parameters
+            post_data: Data to send in the body (will be converted to
+                              json by default)
+            raw: If True, do not convert post_data to json
+            streamed: Whether the data should be streamed
+            files: The files to send to the server
+            timeout: The timeout, in seconds, for the request
+            obey_rate_limit: Whether to obey 429 Too Many Request
+                                    responses. Defaults to True.
+            retry_transient_errors: Whether to retry after 500, 502, 503, 504
+                or 52x responses. Defaults to False.
+            max_retries: Max retries after 429 or transient errors,
+                               set to -1 to retry forever. Defaults to 10.
+            extra_headers: Add and override HTTP headers for the request.
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Returns:
+            A requests result object.
+
+        Raises:
+            GitlabHttpError: When the return code is not 2xx
+        """
+        query_data = query_data or {}
+        raw_url = self._build_url(path)
+
+        # parse user-provided URL params to ensure we don't add our own duplicates
+        parsed = parse.urlparse(raw_url)
+        params = parse.parse_qs(parsed.query)
+        utils.copy_dict(src=query_data, dest=params)
+
+        url = parse.urlunparse(parsed._replace(query=""))
+
+        # Deal with kwargs: by default a user uses kwargs to send data to the
+        # gitlab server, but this generates problems (python keyword conflicts
+        # and python-gitlab/gitlab conflicts).
+        # So we provide a `query_parameters` key: if it's there we use its dict
+        # value as arguments for the gitlab server, and ignore the other
+        # arguments, except pagination ones (per_page and page)
+        if "query_parameters" in kwargs:
+            utils.copy_dict(src=kwargs["query_parameters"], dest=params)
+            for arg in ("per_page", "page"):
+                if arg in kwargs:
+                    params[arg] = kwargs[arg]
+        else:
+            utils.copy_dict(src=kwargs, dest=params)
+
+        opts = self._get_session_opts()
+
+        verify = opts.pop("verify")
+        opts_timeout = opts.pop("timeout")
+        # If timeout was passed into kwargs, allow it to override the default
+        if timeout is None:
+            timeout = opts_timeout
+        if retry_transient_errors is None:
+            retry_transient_errors = self.retry_transient_errors
+
+        # We need to deal with json vs. data when uploading files
+        send_data = self._backend.prepare_send_data(files, post_data, raw)
+        opts["headers"]["Content-type"] = send_data.content_type
+
+        if extra_headers is not None:
+            opts["headers"].update(extra_headers)
+
+        retry = utils.Retry(
+            max_retries=max_retries,
+            obey_rate_limit=obey_rate_limit,
+            retry_transient_errors=retry_transient_errors,
+        )
+
+        while True:
+            try:
+                result = self._backend.http_request(
+                    method=verb,
+                    url=url,
+                    json=send_data.json,
+                    data=send_data.data,
+                    params=params,
+                    timeout=timeout,
+                    verify=verify,
+                    stream=streamed,
+                    **opts,
+                )
+            except (requests.ConnectionError, requests.exceptions.ChunkedEncodingError):
+                if retry.handle_retry():
+                    continue
+                raise
+
+            self._check_redirects(result.response)
+
+            if 200 <= result.status_code < 300:
+                return result.response
+
+            if retry.handle_retry_on_status(
+                result.status_code, result.headers, result.reason
+            ):
+                continue
+
+            error_message = result.content
+            try:
+                error_json = result.json()
+                for k in ("message", "error"):
+                    if k in error_json:
+                        error_message = error_json[k]
+            except (KeyError, ValueError, TypeError):
+                pass
+
+            if result.status_code == 401:
+                raise gitlab.exceptions.GitlabAuthenticationError(
+                    response_code=result.status_code,
+                    error_message=error_message,
+                    response_body=result.content,
+                )
+
+            raise gitlab.exceptions.GitlabHttpError(
+                response_code=result.status_code,
+                error_message=error_message,
+                response_body=result.content,
+            )
+
+    def http_get(
+        self,
+        path: str,
+        query_data: dict[str, Any] | None = None,
+        streamed: bool = False,
+        raw: bool = False,
+        **kwargs: Any,
+    ) -> dict[str, Any] | requests.Response:
+        """Make a GET request to the Gitlab server.
+
+        Args:
+            path: Path or full URL to query ('/projects' or
+                        'http://whatever/v4/api/projecs')
+            query_data: Data to send as query parameters
+            streamed: Whether the data should be streamed
+            raw: If True do not try to parse the output as json
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Returns:
+            A requests result object is streamed is True or the content type is
+            not json.
+            The parsed json data otherwise.
+
+        Raises:
+            GitlabHttpError: When the return code is not 2xx
+            GitlabParsingError: If the json data could not be parsed
+        """
+        query_data = query_data or {}
+        result = self.http_request(
+            "get", path, query_data=query_data, streamed=streamed, **kwargs
+        )
+        content_type = utils.get_content_type(result.headers.get("Content-Type"))
+
+        if content_type == "application/json" and not streamed and not raw:
+            try:
+                json_result = result.json()
+                if TYPE_CHECKING:
+                    assert isinstance(json_result, dict)
+                return json_result
+            except Exception as e:
+                raise gitlab.exceptions.GitlabParsingError(
+                    error_message="Failed to parse the server message"
+                ) from e
+        else:
+            return result
+
+    def http_head(
+        self, path: str, query_data: dict[str, Any] | None = None, **kwargs: Any
+    ) -> requests.structures.CaseInsensitiveDict[Any]:
+        """Make a HEAD request to the Gitlab server.
+
+        Args:
+            path: Path or full URL to query ('/projects' or
+                        'http://whatever/v4/api/projecs')
+            query_data: Data to send as query parameters
+            **kwargs: Extra options to send to the server (e.g. sudo, page,
+                      per_page)
+        Returns:
+            A requests.header object
+        Raises:
+            GitlabHttpError: When the return code is not 2xx
+        """
+
+        query_data = query_data or {}
+        result = self.http_request("head", path, query_data=query_data, **kwargs)
+        return result.headers
+
+    def http_list(
+        self,
+        path: str,
+        query_data: dict[str, Any] | None = None,
+        *,
+        iterator: bool | None = None,
+        message_details: utils.WarnMessageData | None = None,
+        **kwargs: Any,
+    ) -> GitlabList | list[dict[str, Any]]:
+        """Make a GET request to the Gitlab server for list-oriented queries.
+
+        Args:
+            path: Path or full URL to query ('/projects' or
+                        'http://whatever/v4/api/projects')
+            query_data: Data to send as query parameters
+            iterator: Indicate if should return a generator (True)
+            **kwargs: Extra options to send to the server (e.g. sudo, page,
+                      per_page)
+
+        Returns:
+            A list of the objects returned by the server. If `iterator` is
+            True and no pagination-related arguments (`page`, `per_page`,
+            `get_all`) are defined then a GitlabList object (generator) is returned
+            instead. This object will make API calls when needed to fetch the
+            next items from the server.
+
+        Raises:
+            GitlabHttpError: When the return code is not 2xx
+            GitlabParsingError: If the json data could not be parsed
+        """
+        query_data = query_data or {}
+
+        # Provide a `get_all`` param to avoid clashes with `all` API attributes.
+        get_all = kwargs.pop("get_all", None)
+
+        if get_all is None:
+            # For now, keep `all` without deprecation.
+            get_all = kwargs.pop("all", None)
+
+        url = self._build_url(path)
+
+        page = kwargs.get("page")
+
+        if iterator:
+            if page is not None:
+                utils.warn(
+                    message=(
+                        f"`{iterator=}` and `{page=}` were both specified. "
+                        f"`{page=}` will be ignored."
+                    ),
+                    category=UserWarning,
+                )
+
+            # Generator requested
+            return GitlabList(self, url, query_data, **kwargs)
+
+        if get_all is True:
+            return list(GitlabList(self, url, query_data, **kwargs))
+
+        # pagination requested, we return a list
+        gl_list = GitlabList(self, url, query_data, get_next=False, **kwargs)
+        items = list(gl_list)
+
+        def should_emit_warning() -> bool:
+            # No warning is emitted if any of the following conditions apply:
+            # * `get_all=False` was set in the `list()` call.
+            # * `page` was set in the `list()` call.
+            # * GitLab did not return the `x-per-page` header.
+            # * Number of items received is less than per-page value.
+            # * Number of items received is >= total available.
+            if get_all is False:
+                return False
+            if page is not None:
+                return False
+            if gl_list.per_page is None:
+                return False
+            if len(items) < gl_list.per_page:
+                return False
+            if gl_list.total is not None and len(items) >= gl_list.total:
+                return False
+            return True
+
+        if not should_emit_warning():
+            return items
+
+        # Warn the user that they are only going to retrieve `per_page`
+        # maximum items. This is a common cause of issues filed.
+        total_items = "many" if gl_list.total is None else gl_list.total
+        if message_details is not None:
+            message = message_details.message.format_map(
+                {
+                    "len_items": len(items),
+                    "per_page": gl_list.per_page,
+                    "total_items": total_items,
+                }
+            )
+            show_caller = message_details.show_caller
+        else:
+            message = (
+                f"Calling a `list()` method without specifying `get_all=True` or "
+                f"`iterator=True` will return a maximum of {gl_list.per_page} items. "
+                f"Your query returned {len(items)} of {total_items} items. See "
+                f"{_PAGINATION_URL} for more details. If this was done intentionally, "
+                f"then this warning can be supressed by adding the argument "
+                f"`get_all=False` to the `list()` call."
+            )
+            show_caller = True
+        utils.warn(message=message, category=UserWarning, show_caller=show_caller)
+        return items
+
+    def http_post(
+        self,
+        path: str,
+        query_data: dict[str, Any] | None = None,
+        post_data: dict[str, Any] | None = None,
+        raw: bool = False,
+        files: dict[str, Any] | None = None,
+        **kwargs: Any,
+    ) -> dict[str, Any] | requests.Response:
+        """Make a POST request to the Gitlab server.
+
+        Args:
+            path: Path or full URL to query ('/projects' or
+                        'http://whatever/v4/api/projecs')
+            query_data: Data to send as query parameters
+            post_data: Data to send in the body (will be converted to
+                              json by default)
+            raw: If True, do not convert post_data to json
+            files: The files to send to the server
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Returns:
+            The parsed json returned by the server if json is return, else the
+            raw content
+
+        Raises:
+            GitlabHttpError: When the return code is not 2xx
+            GitlabParsingError: If the json data could not be parsed
+        """
+        query_data = query_data or {}
+        post_data = post_data or {}
+
+        result = self.http_request(
+            "post",
+            path,
+            query_data=query_data,
+            post_data=post_data,
+            files=files,
+            raw=raw,
+            **kwargs,
+        )
+        content_type = utils.get_content_type(result.headers.get("Content-Type"))
+
+        try:
+            if content_type == "application/json":
+                json_result = result.json()
+                if TYPE_CHECKING:
+                    assert isinstance(json_result, dict)
+                return json_result
+        except Exception as e:
+            raise gitlab.exceptions.GitlabParsingError(
+                error_message="Failed to parse the server message"
+            ) from e
+        return result
+
+    def http_put(
+        self,
+        path: str,
+        query_data: dict[str, Any] | None = None,
+        post_data: dict[str, Any] | bytes | BinaryIO | None = None,
+        raw: bool = False,
+        files: dict[str, Any] | None = None,
+        **kwargs: Any,
+    ) -> dict[str, Any] | requests.Response:
+        """Make a PUT request to the Gitlab server.
+
+        Args:
+            path: Path or full URL to query ('/projects' or
+                        'http://whatever/v4/api/projecs')
+            query_data: Data to send as query parameters
+            post_data: Data to send in the body (will be converted to
+                              json by default)
+            raw: If True, do not convert post_data to json
+            files: The files to send to the server
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Returns:
+            The parsed json returned by the server.
+
+        Raises:
+            GitlabHttpError: When the return code is not 2xx
+            GitlabParsingError: If the json data could not be parsed
+        """
+        query_data = query_data or {}
+        post_data = post_data or {}
+
+        result = self.http_request(
+            "put",
+            path,
+            query_data=query_data,
+            post_data=post_data,
+            files=files,
+            raw=raw,
+            **kwargs,
+        )
+        if result.status_code in gitlab.const.NO_JSON_RESPONSE_CODES:
+            return result
+        try:
+            json_result = result.json()
+            if TYPE_CHECKING:
+                assert isinstance(json_result, dict)
+            return json_result
+        except Exception as e:
+            raise gitlab.exceptions.GitlabParsingError(
+                error_message="Failed to parse the server message"
+            ) from e
+
+    def http_patch(
+        self,
+        path: str,
+        *,
+        query_data: dict[str, Any] | None = None,
+        post_data: dict[str, Any] | bytes | None = None,
+        raw: bool = False,
+        **kwargs: Any,
+    ) -> dict[str, Any] | requests.Response:
+        """Make a PATCH request to the Gitlab server.
+
+        Args:
+            path: Path or full URL to query ('/projects' or
+                        'http://whatever/v4/api/projecs')
+            query_data: Data to send as query parameters
+            post_data: Data to send in the body (will be converted to
+                              json by default)
+            raw: If True, do not convert post_data to json
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Returns:
+            The parsed json returned by the server.
+
+        Raises:
+            GitlabHttpError: When the return code is not 2xx
+            GitlabParsingError: If the json data could not be parsed
+        """
+        query_data = query_data or {}
+        post_data = post_data or {}
+
+        result = self.http_request(
+            "patch", path, query_data=query_data, post_data=post_data, raw=raw, **kwargs
+        )
+        if result.status_code in gitlab.const.NO_JSON_RESPONSE_CODES:
+            return result
+        try:
+            json_result = result.json()
+            if TYPE_CHECKING:
+                assert isinstance(json_result, dict)
+            return json_result
+        except Exception as e:
+            raise gitlab.exceptions.GitlabParsingError(
+                error_message="Failed to parse the server message"
+            ) from e
+
+    def http_delete(self, path: str, **kwargs: Any) -> requests.Response:
+        """Make a DELETE request to the Gitlab server.
+
+        Args:
+            path: Path or full URL to query ('/projects' or
+                        'http://whatever/v4/api/projecs')
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Returns:
+            The requests object.
+
+        Raises:
+            GitlabHttpError: When the return code is not 2xx
+        """
+        return self.http_request("delete", path, **kwargs)
+
+    @gitlab.exceptions.on_http_error(gitlab.exceptions.GitlabSearchError)
+    def search(
+        self, scope: str, search: str, **kwargs: Any
+    ) -> GitlabList | list[dict[str, Any]]:
+        """Search GitLab resources matching the provided string.'
+
+        Args:
+            scope: Scope of the search
+            search: Search string
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabSearchError: If the server failed to perform the request
+
+        Returns:
+            A list of dicts describing the resources found.
+        """
+        data = {"scope": scope, "search": search}
+        return self.http_list("/search", query_data=data, **kwargs)
+
+
+class GitlabList:
+    """Generator representing a list of remote objects.
+
+    The object handles the links returned by a query to the API, and will call
+    the API again when needed.
+    """
+
+    def __init__(
+        self,
+        gl: Gitlab,
+        url: str,
+        query_data: dict[str, Any],
+        get_next: bool = True,
+        **kwargs: Any,
+    ) -> None:
+        self._gl = gl
+
+        # Preserve kwargs for subsequent queries
+        self._kwargs = kwargs.copy()
+
+        self._query(url, query_data, **self._kwargs)
+        self._get_next = get_next
+
+        # Remove query_parameters from kwargs, which are saved via the `next` URL
+        self._kwargs.pop("query_parameters", None)
+
+    def _query(
+        self, url: str, query_data: dict[str, Any] | None = None, **kwargs: Any
+    ) -> None:
+        query_data = query_data or {}
+        result = self._gl.http_request("get", url, query_data=query_data, **kwargs)
+        try:
+            next_url = result.links["next"]["url"]
+        except KeyError:
+            next_url = None
+
+        self._next_url = self._gl._check_url(next_url)
+        self._current_page: str | None = result.headers.get("X-Page")
+        self._prev_page: str | None = result.headers.get("X-Prev-Page")
+        self._next_page: str | None = result.headers.get("X-Next-Page")
+        self._per_page: str | None = result.headers.get("X-Per-Page")
+        self._total_pages: str | None = result.headers.get("X-Total-Pages")
+        self._total: str | None = result.headers.get("X-Total")
+
+        try:
+            self._data: list[dict[str, Any]] = result.json()
+        except Exception as e:
+            raise gitlab.exceptions.GitlabParsingError(
+                error_message="Failed to parse the server message"
+            ) from e
+
+        self._current = 0
+
+    @property
+    def current_page(self) -> int:
+        """The current page number."""
+        if TYPE_CHECKING:
+            assert self._current_page is not None
+        return int(self._current_page)
+
+    @property
+    def prev_page(self) -> int | None:
+        """The previous page number.
+
+        If None, the current page is the first.
+        """
+        return int(self._prev_page) if self._prev_page else None
+
+    @property
+    def next_page(self) -> int | None:
+        """The next page number.
+
+        If None, the current page is the last.
+        """
+        return int(self._next_page) if self._next_page else None
+
+    @property
+    def per_page(self) -> int | None:
+        """The number of items per page."""
+        return int(self._per_page) if self._per_page is not None else None
+
+    # NOTE(jlvillal): When a query returns more than 10,000 items, GitLab doesn't return
+    # the headers 'x-total-pages' and 'x-total'. In those cases we return None.
+    # https://docs.gitlab.com/ee/user/gitlab_com/index.html#pagination-response-headers
+    @property
+    def total_pages(self) -> int | None:
+        """The total number of pages."""
+        if self._total_pages is not None:
+            return int(self._total_pages)
+        return None
+
+    @property
+    def total(self) -> int | None:
+        """The total number of items."""
+        if self._total is not None:
+            return int(self._total)
+        return None
+
+    def __iter__(self) -> GitlabList:
+        return self
+
+    def __len__(self) -> int:
+        if self._total is None:
+            return 0
+        return int(self._total)
+
+    def __next__(self) -> dict[str, Any]:
+        return self.next()
+
+    def next(self) -> dict[str, Any]:
+        try:
+            item = self._data[self._current]
+            self._current += 1
+            return item
+        except IndexError:
+            pass
+
+        if self._next_url and self._get_next is True:
+            self._query(self._next_url, **self._kwargs)
+            return self.next()
+
+        raise StopIteration
+
+
+class _BaseGraphQL:
+    def __init__(
+        self,
+        url: str | None = None,
+        *,
+        token: str | None = None,
+        ssl_verify: bool | str = True,
+        timeout: float | None = None,
+        user_agent: str = gitlab.const.USER_AGENT,
+        fetch_schema_from_transport: bool = False,
+        max_retries: int = 10,
+        obey_rate_limit: bool = True,
+        retry_transient_errors: bool = False,
+    ) -> None:
+        if not _GQL_INSTALLED:
+            raise ImportError(
+                "The GraphQL client could not be initialized because "
+                "the gql dependencies are not installed. "
+                "Install them with 'pip install python-gitlab[graphql]'"
+            )
+        self._base_url = utils.get_base_url(url)
+        self._timeout = timeout
+        self._token = token
+        self._url = f"{self._base_url}/api/graphql"
+        self._user_agent = user_agent
+        self._ssl_verify = ssl_verify
+        self._max_retries = max_retries
+        self._obey_rate_limit = obey_rate_limit
+        self._retry_transient_errors = retry_transient_errors
+        self._client_opts = self._get_client_opts()
+        self._fetch_schema_from_transport = fetch_schema_from_transport
+
+    def _get_client_opts(self) -> dict[str, Any]:
+        headers = {"User-Agent": self._user_agent}
+
+        if self._token:
+            headers["Authorization"] = f"Bearer {self._token}"
+
+        return {
+            "headers": headers,
+            "timeout": self._timeout,
+            "verify": self._ssl_verify,
+        }
+
+
+class GraphQL(_BaseGraphQL):
+    def __init__(
+        self,
+        url: str | None = None,
+        *,
+        token: str | None = None,
+        ssl_verify: bool | str = True,
+        client: httpx.Client | None = None,
+        timeout: float | None = None,
+        user_agent: str = gitlab.const.USER_AGENT,
+        fetch_schema_from_transport: bool = False,
+        max_retries: int = 10,
+        obey_rate_limit: bool = True,
+        retry_transient_errors: bool = False,
+    ) -> None:
+        super().__init__(
+            url=url,
+            token=token,
+            ssl_verify=ssl_verify,
+            timeout=timeout,
+            user_agent=user_agent,
+            fetch_schema_from_transport=fetch_schema_from_transport,
+            max_retries=max_retries,
+            obey_rate_limit=obey_rate_limit,
+            retry_transient_errors=retry_transient_errors,
+        )
+
+        self._http_client = client or httpx.Client(**self._client_opts)
+        self._transport = GitlabTransport(self._url, client=self._http_client)
+        self._client = gql.Client(
+            transport=self._transport,
+            fetch_schema_from_transport=fetch_schema_from_transport,
+        )
+        self._gql = gql.gql
+
+    def __enter__(self) -> GraphQL:
+        return self
+
+    def __exit__(self, *args: Any) -> None:
+        self._http_client.close()
+
+    def execute(self, request: str | graphql.Source, *args: Any, **kwargs: Any) -> Any:
+        parsed_document = self._gql(request)
+        retry = utils.Retry(
+            max_retries=self._max_retries,
+            obey_rate_limit=self._obey_rate_limit,
+            retry_transient_errors=self._retry_transient_errors,
+        )
+
+        while True:
+            try:
+                result = self._client.execute(parsed_document, *args, **kwargs)
+            except gql.transport.exceptions.TransportServerError as e:
+                if retry.handle_retry_on_status(
+                    status_code=e.code, headers=self._transport.response_headers
+                ):
+                    continue
+
+                if e.code == 401:
+                    raise gitlab.exceptions.GitlabAuthenticationError(
+                        response_code=e.code, error_message=str(e)
+                    )
+
+                raise gitlab.exceptions.GitlabHttpError(
+                    response_code=e.code, error_message=str(e)
+                )
+
+            return result
+
+
+class AsyncGraphQL(_BaseGraphQL):
+    def __init__(
+        self,
+        url: str | None = None,
+        *,
+        token: str | None = None,
+        ssl_verify: bool | str = True,
+        client: httpx.AsyncClient | None = None,
+        timeout: float | None = None,
+        user_agent: str = gitlab.const.USER_AGENT,
+        fetch_schema_from_transport: bool = False,
+        max_retries: int = 10,
+        obey_rate_limit: bool = True,
+        retry_transient_errors: bool = False,
+    ) -> None:
+        super().__init__(
+            url=url,
+            token=token,
+            ssl_verify=ssl_verify,
+            timeout=timeout,
+            user_agent=user_agent,
+            fetch_schema_from_transport=fetch_schema_from_transport,
+            max_retries=max_retries,
+            obey_rate_limit=obey_rate_limit,
+            retry_transient_errors=retry_transient_errors,
+        )
+
+        self._http_client = client or httpx.AsyncClient(**self._client_opts)
+        self._transport = GitlabAsyncTransport(self._url, client=self._http_client)
+        self._client = gql.Client(
+            transport=self._transport,
+            fetch_schema_from_transport=fetch_schema_from_transport,
+        )
+        self._gql = gql.gql
+
+    async def __aenter__(self) -> AsyncGraphQL:
+        return self
+
+    async def __aexit__(self, *args: Any) -> None:
+        await self._http_client.aclose()
+
+    async def execute(
+        self, request: str | graphql.Source, *args: Any, **kwargs: Any
+    ) -> Any:
+        parsed_document = self._gql(request)
+        retry = utils.Retry(
+            max_retries=self._max_retries,
+            obey_rate_limit=self._obey_rate_limit,
+            retry_transient_errors=self._retry_transient_errors,
+        )
+
+        while True:
+            try:
+                result = await self._client.execute_async(
+                    parsed_document, *args, **kwargs
+                )
+            except gql.transport.exceptions.TransportServerError as e:
+                if retry.handle_retry_on_status(
+                    status_code=e.code, headers=self._transport.response_headers
+                ):
+                    continue
+
+                if e.code == 401:
+                    raise gitlab.exceptions.GitlabAuthenticationError(
+                        response_code=e.code, error_message=str(e)
+                    )
+
+                raise gitlab.exceptions.GitlabHttpError(
+                    response_code=e.code, error_message=str(e)
+                )
+
+            return result
diff --git a/gitlab/config.py b/gitlab/config.py
index 3ef2efb03..46be3e26d 100644
--- a/gitlab/config.py
+++ b/gitlab/config.py
@@ -1,29 +1,71 @@
-# -*- coding: utf-8 -*-
-#
-# Copyright (C) 2013-2015 Gauvain Pocentek <gauvain@pocentek.net>
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Lesser General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Lesser General Public License for more details.
-#
-# You should have received a copy of the GNU Lesser General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+from __future__ import annotations
 
+import configparser
 import os
+import shlex
+import subprocess
+from os.path import expanduser, expandvars
+from pathlib import Path
 
-from six.moves import configparser
+from gitlab.const import USER_AGENT
 
-_DEFAULT_FILES = [
-    '/etc/python-gitlab.cfg',
-    os.path.expanduser('~/.python-gitlab.cfg')
+_DEFAULT_FILES: list[str] = [
+    "/etc/python-gitlab.cfg",
+    str(Path.home() / ".python-gitlab.cfg"),
 ]
 
+HELPER_PREFIX = "helper:"
+
+HELPER_ATTRIBUTES = ["job_token", "http_password", "private_token", "oauth_token"]
+
+_CONFIG_PARSER_ERRORS = (configparser.NoOptionError, configparser.NoSectionError)
+
+
+def _resolve_file(filepath: Path | str) -> str:
+    resolved = Path(filepath).resolve(strict=True)
+    return str(resolved)
+
+
+def _get_config_files(config_files: list[str] | None = None) -> str | list[str]:
+    """
+    Return resolved path(s) to config files if they exist, with precedence:
+    1. Files passed in config_files
+    2. File defined in PYTHON_GITLAB_CFG
+    3. User- and system-wide config files
+    """
+    resolved_files = []
+
+    if config_files:
+        for config_file in config_files:
+            try:
+                resolved = _resolve_file(config_file)
+            except OSError as e:
+                raise GitlabConfigMissingError(
+                    f"Cannot read config from file: {e}"
+                ) from e
+            resolved_files.append(resolved)
+
+        return resolved_files
+
+    try:
+        env_config = os.environ["PYTHON_GITLAB_CFG"]
+        return _resolve_file(env_config)
+    except KeyError:
+        pass
+    except OSError as e:
+        raise GitlabConfigMissingError(
+            f"Cannot read config from PYTHON_GITLAB_CFG: {e}"
+        ) from e
+
+    for config_file in _DEFAULT_FILES:
+        try:
+            resolved = _resolve_file(config_file)
+        except OSError:
+            continue
+        resolved_files.append(resolved)
+
+    return resolved_files
+
 
 class ConfigError(Exception):
     pass
@@ -37,54 +79,208 @@ class GitlabDataError(ConfigError):
     pass
 
 
-class GitlabConfigParser(object):
-    def __init__(self, gitlab_id=None, config_files=None):
+class GitlabConfigMissingError(ConfigError):
+    pass
+
+
+class GitlabConfigHelperError(ConfigError):
+    pass
+
+
+class GitlabConfigParser:
+    def __init__(
+        self, gitlab_id: str | None = None, config_files: list[str] | None = None
+    ) -> None:
         self.gitlab_id = gitlab_id
-        _files = config_files or _DEFAULT_FILES
-        self._config = configparser.ConfigParser()
-        self._config.read(_files)
+        self.http_username: str | None = None
+        self.http_password: str | None = None
+        self.job_token: str | None = None
+        self.oauth_token: str | None = None
+        self.private_token: str | None = None
+
+        self.api_version: str = "4"
+        self.order_by: str | None = None
+        self.pagination: str | None = None
+        self.per_page: int | None = None
+        self.retry_transient_errors: bool = False
+        self.ssl_verify: bool | str = True
+        self.timeout: int = 60
+        self.url: str | None = None
+        self.user_agent: str = USER_AGENT
+        self.keep_base_url: bool = False
+
+        self._files = _get_config_files(config_files)
+        if self._files:
+            self._parse_config()
+
+        if self.gitlab_id and not self._files:
+            raise GitlabConfigMissingError(
+                f"A gitlab id was provided ({self.gitlab_id}) but no config file found"
+            )
+
+    def _parse_config(self) -> None:
+        _config = configparser.ConfigParser()
+        _config.read(self._files, encoding="utf-8")
+
+        if self.gitlab_id and not _config.has_section(self.gitlab_id):
+            raise GitlabDataError(
+                f"A gitlab id was provided ({self.gitlab_id}) "
+                "but no config section found"
+            )
 
         if self.gitlab_id is None:
             try:
-                self.gitlab_id = self._config.get('global', 'default')
-            except Exception:
-                raise GitlabIDError("Impossible to get the gitlab id "
-                                    "(not specified in config file)")
+                self.gitlab_id = _config.get("global", "default")
+            except Exception as e:
+                raise GitlabIDError(
+                    "Impossible to get the gitlab id (not specified in config file)"
+                ) from e
 
         try:
-            self.url = self._config.get(self.gitlab_id, 'url')
-            self.token = self._config.get(self.gitlab_id, 'private_token')
-        except Exception:
-            raise GitlabDataError("Impossible to get gitlab informations from "
-                                  "configuration (%s)" % self.gitlab_id)
+            self.url = _config.get(self.gitlab_id, "url")
+        except Exception as e:
+            raise GitlabDataError(
+                "Impossible to get gitlab details from "
+                f"configuration ({self.gitlab_id})"
+            ) from e
 
-        self.ssl_verify = True
         try:
-            self.ssl_verify = self._config.getboolean('global', 'ssl_verify')
-        except Exception:
+            self.ssl_verify = _config.getboolean("global", "ssl_verify")
+        except ValueError:
+            # Value Error means the option exists but isn't a boolean.
+            # Get as a string instead as it should then be a local path to a
+            # CA bundle.
+            self.ssl_verify = _config.get("global", "ssl_verify")
+        except _CONFIG_PARSER_ERRORS:
             pass
         try:
-            self.ssl_verify = self._config.getboolean(self.gitlab_id,
-                                                      'ssl_verify')
-        except Exception:
+            self.ssl_verify = _config.getboolean(self.gitlab_id, "ssl_verify")
+        except ValueError:
+            # Value Error means the option exists but isn't a boolean.
+            # Get as a string instead as it should then be a local path to a
+            # CA bundle.
+            self.ssl_verify = _config.get(self.gitlab_id, "ssl_verify")
+        except _CONFIG_PARSER_ERRORS:
             pass
 
-        self.timeout = 60
         try:
-            self.timeout = self._config.getint('global', 'timeout')
-        except Exception:
+            self.timeout = _config.getint("global", "timeout")
+        except _CONFIG_PARSER_ERRORS:
             pass
         try:
-            self.timeout = self._config.getint(self.gitlab_id, 'timeout')
-        except Exception:
+            self.timeout = _config.getint(self.gitlab_id, "timeout")
+        except _CONFIG_PARSER_ERRORS:
             pass
 
-        self.http_username = None
-        self.http_password = None
         try:
-            self.http_username = self._config.get(self.gitlab_id,
-                                                  'http_username')
-            self.http_password = self._config.get(self.gitlab_id,
-                                                  'http_password')
-        except Exception:
+            self.private_token = _config.get(self.gitlab_id, "private_token")
+        except _CONFIG_PARSER_ERRORS:
             pass
+
+        try:
+            self.oauth_token = _config.get(self.gitlab_id, "oauth_token")
+        except _CONFIG_PARSER_ERRORS:
+            pass
+
+        try:
+            self.job_token = _config.get(self.gitlab_id, "job_token")
+        except _CONFIG_PARSER_ERRORS:
+            pass
+
+        try:
+            self.http_username = _config.get(self.gitlab_id, "http_username")
+            self.http_password = _config.get(
+                self.gitlab_id, "http_password"
+            )  # pragma: no cover
+        except _CONFIG_PARSER_ERRORS:
+            pass
+
+        self._get_values_from_helper()
+
+        try:
+            self.api_version = _config.get("global", "api_version")
+        except _CONFIG_PARSER_ERRORS:
+            pass
+        try:
+            self.api_version = _config.get(self.gitlab_id, "api_version")
+        except _CONFIG_PARSER_ERRORS:
+            pass
+        if self.api_version not in ("4",):
+            raise GitlabDataError(f"Unsupported API version: {self.api_version}")
+
+        for section in ["global", self.gitlab_id]:
+            try:
+                self.per_page = _config.getint(section, "per_page")
+            except _CONFIG_PARSER_ERRORS:
+                pass
+        if self.per_page is not None and not 0 <= self.per_page <= 100:
+            raise GitlabDataError(f"Unsupported per_page number: {self.per_page}")
+
+        try:
+            self.pagination = _config.get(self.gitlab_id, "pagination")
+        except _CONFIG_PARSER_ERRORS:
+            pass
+
+        try:
+            self.order_by = _config.get(self.gitlab_id, "order_by")
+        except _CONFIG_PARSER_ERRORS:
+            pass
+
+        try:
+            self.user_agent = _config.get("global", "user_agent")
+        except _CONFIG_PARSER_ERRORS:
+            pass
+        try:
+            self.user_agent = _config.get(self.gitlab_id, "user_agent")
+        except _CONFIG_PARSER_ERRORS:
+            pass
+
+        try:
+            self.keep_base_url = _config.getboolean("global", "keep_base_url")
+        except _CONFIG_PARSER_ERRORS:
+            pass
+        try:
+            self.keep_base_url = _config.getboolean(self.gitlab_id, "keep_base_url")
+        except _CONFIG_PARSER_ERRORS:
+            pass
+
+        try:
+            self.retry_transient_errors = _config.getboolean(
+                "global", "retry_transient_errors"
+            )
+        except _CONFIG_PARSER_ERRORS:
+            pass
+        try:
+            self.retry_transient_errors = _config.getboolean(
+                self.gitlab_id, "retry_transient_errors"
+            )
+        except _CONFIG_PARSER_ERRORS:
+            pass
+
+    def _get_values_from_helper(self) -> None:
+        """Update attributes that may get values from an external helper program"""
+        for attr in HELPER_ATTRIBUTES:
+            value = getattr(self, attr)
+            if not isinstance(value, str):
+                continue
+
+            if not value.lower().strip().startswith(HELPER_PREFIX):
+                continue
+
+            helper = value[len(HELPER_PREFIX) :].strip()
+            commmand = [expanduser(expandvars(token)) for token in shlex.split(helper)]
+
+            try:
+                value = (
+                    subprocess.check_output(commmand, stderr=subprocess.PIPE)
+                    .decode("utf-8")
+                    .strip()
+                )
+            except subprocess.CalledProcessError as e:
+                stderr = e.stderr.decode().strip()
+                raise GitlabConfigHelperError(
+                    f"Failed to read {attr} value from helper "
+                    f"for {self.gitlab_id}:\n{stderr}"
+                ) from e
+
+            setattr(self, attr, value)
diff --git a/gitlab/const.py b/gitlab/const.py
index 99a174569..7a0492e64 100644
--- a/gitlab/const.py
+++ b/gitlab/const.py
@@ -1,33 +1,171 @@
-# -*- coding: utf-8 -*-
-#
-# Copyright (C) 2016 Gauvain Pocentek <gauvain@pocentek.net>
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Lesser General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Lesser General Public License for more details.
-#
-# You should have received a copy of the GNU Lesser General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-GUEST_ACCESS = 10
-REPORTER_ACCESS = 20
-DEVELOPER_ACCESS = 30
-MASTER_ACCESS = 40
-OWNER_ACCESS = 50
-
-VISIBILITY_PRIVATE = 0
-VISIBILITY_INTERNAL = 10
-VISIBILITY_PUBLIC = 20
-
-NOTIFICATION_LEVEL_DISABLED = 'disabled'
-NOTIFICATION_LEVEL_PARTICIPATING = 'participating'
-NOTIFICATION_LEVEL_WATCH = 'watch'
-NOTIFICATION_LEVEL_GLOBAL = 'global'
-NOTIFICATION_LEVEL_MENTION = 'mention'
-NOTIFICATION_LEVEL_CUSTOM = 'custom'
+from enum import Enum, IntEnum
+
+from gitlab._version import __title__, __version__
+
+
+class GitlabEnum(str, Enum):
+    """An enum mixed in with str to make it JSON-serializable."""
+
+
+# https://gitlab.com/gitlab-org/gitlab/-/blob/e97357824bedf007e75f8782259fe07435b64fbb/lib/gitlab/access.rb#L12-18
+class AccessLevel(IntEnum):
+    NO_ACCESS = 0
+    MINIMAL_ACCESS = 5
+    GUEST = 10
+    PLANNER = 15
+    REPORTER = 20
+    DEVELOPER = 30
+    MAINTAINER = 40
+    OWNER = 50
+    ADMIN = 60
+
+
+# https://gitlab.com/gitlab-org/gitlab/-/blob/e97357824bedf007e75f8782259fe07435b64fbb/lib/gitlab/visibility_level.rb#L23-25
+class Visibility(GitlabEnum):
+    PRIVATE = "private"
+    INTERNAL = "internal"
+    PUBLIC = "public"
+
+
+class NotificationLevel(GitlabEnum):
+    DISABLED = "disabled"
+    PARTICIPATING = "participating"
+    WATCH = "watch"
+    GLOBAL = "global"
+    MENTION = "mention"
+    CUSTOM = "custom"
+
+
+# https://gitlab.com/gitlab-org/gitlab/-/blob/e97357824bedf007e75f8782259fe07435b64fbb/app/views/search/_category.html.haml#L10-37
+class SearchScope(GitlabEnum):
+    # all scopes (global, group and  project)
+    PROJECTS = "projects"
+    ISSUES = "issues"
+    MERGE_REQUESTS = "merge_requests"
+    MILESTONES = "milestones"
+    WIKI_BLOBS = "wiki_blobs"
+    COMMITS = "commits"
+    BLOBS = "blobs"
+    USERS = "users"
+
+    # specific global scope
+    GLOBAL_SNIPPET_TITLES = "snippet_titles"
+
+    # specific project scope
+    PROJECT_NOTES = "notes"
+
+
+# https://docs.gitlab.com/ee/api/merge_requests.html#merge-status
+class DetailedMergeStatus(GitlabEnum):
+    # possible values for the detailed_merge_status field of Merge Requests
+    BLOCKED_STATUS = "blocked_status"
+    BROKEN_STATUS = "broken_status"
+    CHECKING = "checking"
+    UNCHECKED = "unchecked"
+    CI_MUST_PASS = "ci_must_pass"
+    CI_STILL_RUNNING = "ci_still_running"
+    DISCUSSIONS_NOT_RESOLVED = "discussions_not_resolved"
+    DRAFT_STATUS = "draft_status"
+    EXTERNAL_STATUS_CHECKS = "external_status_checks"
+    MERGEABLE = "mergeable"
+    NOT_APPROVED = "not_approved"
+    NOT_OPEN = "not_open"
+    POLICIES_DENIED = "policies_denied"
+
+
+# https://docs.gitlab.com/ee/api/pipelines.html
+class PipelineStatus(GitlabEnum):
+    CREATED = "created"
+    WAITING_FOR_RESOURCE = "waiting_for_resource"
+    PREPARING = "preparing"
+    PENDING = "pending"
+    RUNNING = "running"
+    SUCCESS = "success"
+    FAILED = "failed"
+    CANCELED = "canceled"
+    SKIPPED = "skipped"
+    MANUAL = "manual"
+    SCHEDULED = "scheduled"
+
+
+DEFAULT_URL: str = "https://gitlab.com"
+
+NO_ACCESS = AccessLevel.NO_ACCESS.value
+MINIMAL_ACCESS = AccessLevel.MINIMAL_ACCESS.value
+GUEST_ACCESS = AccessLevel.GUEST.value
+PLANNER_ACCESS = AccessLevel.PLANNER.value
+REPORTER_ACCESS = AccessLevel.REPORTER.value
+DEVELOPER_ACCESS = AccessLevel.DEVELOPER.value
+MAINTAINER_ACCESS = AccessLevel.MAINTAINER.value
+OWNER_ACCESS = AccessLevel.OWNER.value
+ADMIN_ACCESS = AccessLevel.ADMIN.value
+
+VISIBILITY_PRIVATE = Visibility.PRIVATE.value
+VISIBILITY_INTERNAL = Visibility.INTERNAL.value
+VISIBILITY_PUBLIC = Visibility.PUBLIC.value
+
+NOTIFICATION_LEVEL_DISABLED = NotificationLevel.DISABLED.value
+NOTIFICATION_LEVEL_PARTICIPATING = NotificationLevel.PARTICIPATING.value
+NOTIFICATION_LEVEL_WATCH = NotificationLevel.WATCH.value
+NOTIFICATION_LEVEL_GLOBAL = NotificationLevel.GLOBAL.value
+NOTIFICATION_LEVEL_MENTION = NotificationLevel.MENTION.value
+NOTIFICATION_LEVEL_CUSTOM = NotificationLevel.CUSTOM.value
+
+# Search scopes
+# all scopes (global, group and  project)
+SEARCH_SCOPE_PROJECTS = SearchScope.PROJECTS.value
+SEARCH_SCOPE_ISSUES = SearchScope.ISSUES.value
+SEARCH_SCOPE_MERGE_REQUESTS = SearchScope.MERGE_REQUESTS.value
+SEARCH_SCOPE_MILESTONES = SearchScope.MILESTONES.value
+SEARCH_SCOPE_WIKI_BLOBS = SearchScope.WIKI_BLOBS.value
+SEARCH_SCOPE_COMMITS = SearchScope.COMMITS.value
+SEARCH_SCOPE_BLOBS = SearchScope.BLOBS.value
+SEARCH_SCOPE_USERS = SearchScope.USERS.value
+
+# specific global scope
+SEARCH_SCOPE_GLOBAL_SNIPPET_TITLES = SearchScope.GLOBAL_SNIPPET_TITLES.value
+
+# specific project scope
+SEARCH_SCOPE_PROJECT_NOTES = SearchScope.PROJECT_NOTES.value
+
+USER_AGENT: str = f"{__title__}/{__version__}"
+
+NO_JSON_RESPONSE_CODES = [204]
+RETRYABLE_TRANSIENT_ERROR_CODES = [500, 502, 503, 504] + list(range(520, 531))
+
+__all__ = [
+    "AccessLevel",
+    "Visibility",
+    "NotificationLevel",
+    "SearchScope",
+    "ADMIN_ACCESS",
+    "DEFAULT_URL",
+    "DEVELOPER_ACCESS",
+    "GUEST_ACCESS",
+    "MAINTAINER_ACCESS",
+    "MINIMAL_ACCESS",
+    "NO_ACCESS",
+    "NOTIFICATION_LEVEL_CUSTOM",
+    "NOTIFICATION_LEVEL_DISABLED",
+    "NOTIFICATION_LEVEL_GLOBAL",
+    "NOTIFICATION_LEVEL_MENTION",
+    "NOTIFICATION_LEVEL_PARTICIPATING",
+    "NOTIFICATION_LEVEL_WATCH",
+    "OWNER_ACCESS",
+    "PLANNER_ACCESS",
+    "REPORTER_ACCESS",
+    "SEARCH_SCOPE_BLOBS",
+    "SEARCH_SCOPE_COMMITS",
+    "SEARCH_SCOPE_GLOBAL_SNIPPET_TITLES",
+    "SEARCH_SCOPE_ISSUES",
+    "SEARCH_SCOPE_MERGE_REQUESTS",
+    "SEARCH_SCOPE_MILESTONES",
+    "SEARCH_SCOPE_PROJECT_NOTES",
+    "SEARCH_SCOPE_PROJECTS",
+    "SEARCH_SCOPE_USERS",
+    "SEARCH_SCOPE_WIKI_BLOBS",
+    "USER_AGENT",
+    "VISIBILITY_INTERNAL",
+    "VISIBILITY_PRIVATE",
+    "VISIBILITY_PUBLIC",
+]
diff --git a/gitlab/exceptions.py b/gitlab/exceptions.py
index 1d1f477b9..7aa42152c 100644
--- a/gitlab/exceptions.py
+++ b/gitlab/exceptions.py
@@ -1,44 +1,55 @@
-# -*- coding: utf-8 -*-
-#
-# Copyright (C) 2013-2015 Gauvain Pocentek <gauvain@pocentek.net>
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Lesser General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Lesser General Public License for more details.
-#
-# You should have received a copy of the GNU Lesser General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+from __future__ import annotations
 
+import functools
+from typing import Any, Callable, cast, TYPE_CHECKING, TypeVar
 
-class GitlabError(Exception):
-    def __init__(self, error_message="", response_code=None,
-                 response_body=None):
 
+class GitlabError(Exception):
+    def __init__(
+        self,
+        error_message: str | bytes = "",
+        response_code: int | None = None,
+        response_body: bytes | None = None,
+    ) -> None:
         Exception.__init__(self, error_message)
         # Http status code
         self.response_code = response_code
         # Full http response
         self.response_body = response_body
         # Parsed error message from gitlab
-        self.error_message = error_message
-
-    def __str__(self):
+        try:
+            # if we receive str/bytes we try to convert to unicode/str to have
+            # consistent message types (see #616)
+            if TYPE_CHECKING:
+                assert isinstance(error_message, bytes)
+            self.error_message = error_message.decode()
+        except Exception:
+            if TYPE_CHECKING:
+                assert isinstance(error_message, str)
+            self.error_message = error_message
+
+    def __str__(self) -> str:
         if self.response_code is not None:
-            return "{0}: {1}".format(self.response_code, self.error_message)
-        else:
-            return "{0}".format(self.error_message)
+            return f"{self.response_code}: {self.error_message}"
+        return f"{self.error_message}"
 
 
 class GitlabAuthenticationError(GitlabError):
     pass
 
 
+class RedirectError(GitlabError):
+    pass
+
+
+class GitlabParsingError(GitlabError):
+    pass
+
+
+class GitlabCiLintError(GitlabError):
+    pass
+
+
 class GitlabConnectionError(GitlabError):
     pass
 
@@ -47,6 +58,10 @@ class GitlabOperationError(GitlabError):
     pass
 
 
+class GitlabHttpError(GitlabError):
+    pass
+
+
 class GitlabListError(GitlabOperationError):
     pass
 
@@ -55,6 +70,10 @@ class GitlabGetError(GitlabOperationError):
     pass
 
 
+class GitlabHeadError(GitlabOperationError):
+    pass
+
+
 class GitlabCreateError(GitlabOperationError):
     pass
 
@@ -67,6 +86,10 @@ class GitlabDeleteError(GitlabOperationError):
     pass
 
 
+class GitlabSetError(GitlabOperationError):
+    pass
+
+
 class GitlabProtectError(GitlabOperationError):
     pass
 
@@ -75,15 +98,19 @@ class GitlabTransferProjectError(GitlabOperationError):
     pass
 
 
+class GitlabGroupTransferError(GitlabOperationError):
+    pass
+
+
 class GitlabProjectDeployKeyError(GitlabOperationError):
     pass
 
 
-class GitlabCancelError(GitlabOperationError):
+class GitlabPromoteError(GitlabOperationError):
     pass
 
 
-class GitlabBuildCancelError(GitlabCancelError):
+class GitlabCancelError(GitlabOperationError):
     pass
 
 
@@ -95,6 +122,10 @@ class GitlabRetryError(GitlabOperationError):
     pass
 
 
+class GitlabBuildCancelError(GitlabCancelError):
+    pass
+
+
 class GitlabBuildRetryError(GitlabRetryError):
     pass
 
@@ -107,6 +138,26 @@ class GitlabBuildEraseError(GitlabRetryError):
     pass
 
 
+class GitlabJobCancelError(GitlabCancelError):
+    pass
+
+
+class GitlabJobRetryError(GitlabRetryError):
+    pass
+
+
+class GitlabJobPlayError(GitlabRetryError):
+    pass
+
+
+class GitlabJobEraseError(GitlabRetryError):
+    pass
+
+
+class GitlabPipelinePlayError(GitlabRetryError):
+    pass
+
+
 class GitlabPipelineRetryError(GitlabRetryError):
     pass
 
@@ -119,6 +170,22 @@ class GitlabUnblockError(GitlabOperationError):
     pass
 
 
+class GitlabDeactivateError(GitlabOperationError):
+    pass
+
+
+class GitlabActivateError(GitlabOperationError):
+    pass
+
+
+class GitlabBanError(GitlabOperationError):
+    pass
+
+
+class GitlabUnbanError(GitlabOperationError):
+    pass
+
+
 class GitlabSubscribeError(GitlabOperationError):
     pass
 
@@ -131,6 +198,18 @@ class GitlabMRForbiddenError(GitlabOperationError):
     pass
 
 
+class GitlabMRApprovalError(GitlabOperationError):
+    pass
+
+
+class GitlabMRRebaseError(GitlabOperationError):
+    pass
+
+
+class GitlabMRResetApprovalError(GitlabOperationError):
+    pass
+
+
 class GitlabMRClosedError(GitlabOperationError):
     pass
 
@@ -143,38 +222,209 @@ class GitlabTodoError(GitlabOperationError):
     pass
 
 
-def raise_error_from_response(response, error, expected_code=200):
-    """Tries to parse gitlab error message from response and raises error.
+class GitlabTopicMergeError(GitlabOperationError):
+    pass
 
-    Do nothing if the response status is the expected one.
 
-    If response status code is 401, raises instead GitlabAuthenticationError.
+class GitlabTimeTrackingError(GitlabOperationError):
+    pass
+
+
+class GitlabUploadError(GitlabOperationError):
+    pass
+
+
+class GitlabAttachFileError(GitlabOperationError):
+    pass
+
+
+class GitlabImportError(GitlabOperationError):
+    pass
+
+
+class GitlabInvitationError(GitlabOperationError):
+    pass
+
+
+class GitlabCherryPickError(GitlabOperationError):
+    pass
+
+
+class GitlabHousekeepingError(GitlabOperationError):
+    pass
+
+
+class GitlabOwnershipError(GitlabOperationError):
+    pass
+
+
+class GitlabSearchError(GitlabOperationError):
+    pass
+
+
+class GitlabStopError(GitlabOperationError):
+    pass
+
+
+class GitlabMarkdownError(GitlabOperationError):
+    pass
+
+
+class GitlabVerifyError(GitlabOperationError):
+    pass
+
+
+class GitlabRenderError(GitlabOperationError):
+    pass
 
-    Args:
-        response: requests response object
-        error: Error-class or dict {return-code => class} of possible error
-               class to raise. Should be inherited from GitLabError
-    """
 
-    if isinstance(expected_code, int):
-        expected_codes = [expected_code]
-    else:
-        expected_codes = expected_code
+class GitlabRepairError(GitlabOperationError):
+    pass
+
+
+class GitlabRestoreError(GitlabOperationError):
+    pass
+
+
+class GitlabRevertError(GitlabOperationError):
+    pass
 
-    if response.status_code in expected_codes:
-        return
 
-    try:
-        message = response.json()['message']
-    except (KeyError, ValueError, TypeError):
-        message = response.content
+class GitlabRotateError(GitlabOperationError):
+    pass
 
-    if isinstance(error, dict):
-        error = error.get(response.status_code, GitlabOperationError)
-    else:
-        if response.status_code == 401:
-            error = GitlabAuthenticationError
 
-    raise error(error_message=message,
-                response_code=response.status_code,
-                response_body=response.content)
+class GitlabLicenseError(GitlabOperationError):
+    pass
+
+
+class GitlabFollowError(GitlabOperationError):
+    pass
+
+
+class GitlabUnfollowError(GitlabOperationError):
+    pass
+
+
+class GitlabUserApproveError(GitlabOperationError):
+    pass
+
+
+class GitlabUserRejectError(GitlabOperationError):
+    pass
+
+
+class GitlabDeploymentApprovalError(GitlabOperationError):
+    pass
+
+
+class GitlabHookTestError(GitlabOperationError):
+    pass
+
+
+# For an explanation of how these type-hints work see:
+# https://mypy.readthedocs.io/en/stable/generics.html#declaring-decorators
+#
+# The goal here is that functions which get decorated will retain their types.
+__F = TypeVar("__F", bound=Callable[..., Any])
+
+
+def on_http_error(error: type[Exception]) -> Callable[[__F], __F]:
+    """Manage GitlabHttpError exceptions.
+
+    This decorator function can be used to catch GitlabHttpError exceptions
+    raise specialized exceptions instead.
+
+    Args:
+        The exception type to raise -- must inherit from GitlabError
+    """
+
+    def wrap(f: __F) -> __F:
+        @functools.wraps(f)
+        def wrapped_f(*args: Any, **kwargs: Any) -> Any:
+            try:
+                return f(*args, **kwargs)
+            except GitlabHttpError as e:
+                raise error(e.error_message, e.response_code, e.response_body) from e
+
+        return cast(__F, wrapped_f)
+
+    return wrap
+
+
+# Export manually to keep mypy happy
+__all__ = [
+    "GitlabActivateError",
+    "GitlabAttachFileError",
+    "GitlabAuthenticationError",
+    "GitlabBanError",
+    "GitlabBlockError",
+    "GitlabBuildCancelError",
+    "GitlabBuildEraseError",
+    "GitlabBuildPlayError",
+    "GitlabBuildRetryError",
+    "GitlabCancelError",
+    "GitlabCherryPickError",
+    "GitlabCiLintError",
+    "GitlabConnectionError",
+    "GitlabCreateError",
+    "GitlabDeactivateError",
+    "GitlabDeleteError",
+    "GitlabDeploymentApprovalError",
+    "GitlabError",
+    "GitlabFollowError",
+    "GitlabGetError",
+    "GitlabGroupTransferError",
+    "GitlabHeadError",
+    "GitlabHookTestError",
+    "GitlabHousekeepingError",
+    "GitlabHttpError",
+    "GitlabImportError",
+    "GitlabInvitationError",
+    "GitlabJobCancelError",
+    "GitlabJobEraseError",
+    "GitlabJobPlayError",
+    "GitlabJobRetryError",
+    "GitlabLicenseError",
+    "GitlabListError",
+    "GitlabMRApprovalError",
+    "GitlabMRClosedError",
+    "GitlabMRForbiddenError",
+    "GitlabMROnBuildSuccessError",
+    "GitlabMRRebaseError",
+    "GitlabMRResetApprovalError",
+    "GitlabMarkdownError",
+    "GitlabOperationError",
+    "GitlabOwnershipError",
+    "GitlabParsingError",
+    "GitlabPipelineCancelError",
+    "GitlabPipelinePlayError",
+    "GitlabPipelineRetryError",
+    "GitlabProjectDeployKeyError",
+    "GitlabPromoteError",
+    "GitlabProtectError",
+    "GitlabRenderError",
+    "GitlabRepairError",
+    "GitlabRestoreError",
+    "GitlabRetryError",
+    "GitlabRevertError",
+    "GitlabRotateError",
+    "GitlabSearchError",
+    "GitlabSetError",
+    "GitlabStopError",
+    "GitlabSubscribeError",
+    "GitlabTimeTrackingError",
+    "GitlabTodoError",
+    "GitlabTopicMergeError",
+    "GitlabTransferProjectError",
+    "GitlabUnbanError",
+    "GitlabUnblockError",
+    "GitlabUnfollowError",
+    "GitlabUnsubscribeError",
+    "GitlabUpdateError",
+    "GitlabUploadError",
+    "GitlabUserApproveError",
+    "GitlabUserRejectError",
+    "GitlabVerifyError",
+    "RedirectError",
+]
diff --git a/gitlab/mixins.py b/gitlab/mixins.py
new file mode 100644
index 000000000..51de97876
--- /dev/null
+++ b/gitlab/mixins.py
@@ -0,0 +1,1050 @@
+from __future__ import annotations
+
+import enum
+from collections.abc import Iterator
+from types import ModuleType
+from typing import Any, Callable, Literal, overload, TYPE_CHECKING
+
+import requests
+
+import gitlab
+from gitlab import base, cli
+from gitlab import exceptions as exc
+from gitlab import utils
+
+__all__ = [
+    "GetMixin",
+    "GetWithoutIdMixin",
+    "RefreshMixin",
+    "ListMixin",
+    "RetrieveMixin",
+    "CreateMixin",
+    "UpdateMixin",
+    "SetMixin",
+    "DeleteMixin",
+    "CRUDMixin",
+    "NoUpdateMixin",
+    "SaveMixin",
+    "ObjectDeleteMixin",
+    "UserAgentDetailMixin",
+    "AccessRequestMixin",
+    "DownloadMixin",
+    "SubscribableMixin",
+    "TodoMixin",
+    "TimeTrackingMixin",
+    "ParticipantsMixin",
+    "BadgeRenderMixin",
+]
+
+if TYPE_CHECKING:
+    # When running mypy we use these as the base classes
+    _RestObjectBase = base.RESTObject
+else:
+    _RestObjectBase = object
+
+
+class HeadMixin(base.RESTManager[base.TObjCls]):
+    @exc.on_http_error(exc.GitlabHeadError)
+    def head(
+        self, id: str | int | None = None, **kwargs: Any
+    ) -> requests.structures.CaseInsensitiveDict[Any]:
+        """Retrieve headers from an endpoint.
+
+        Args:
+            id: ID of the object to retrieve
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Returns:
+            A requests header object.
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabHeadError: If the server cannot perform the request
+        """
+        path = self.path
+        if id is not None:
+            path = f"{path}/{utils.EncodedId(id)}"
+
+        return self.gitlab.http_head(path, **kwargs)
+
+
+class GetMixin(HeadMixin[base.TObjCls]):
+    _optional_get_attrs: tuple[str, ...] = ()
+
+    @exc.on_http_error(exc.GitlabGetError)
+    def get(self, id: str | int, lazy: bool = False, **kwargs: Any) -> base.TObjCls:
+        """Retrieve a single object.
+
+        Args:
+            id: ID of the object to retrieve
+            lazy: If True, don't request the server, but create a
+                         shallow object giving access to the managers. This is
+                         useful if you want to avoid useless calls to the API.
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Returns:
+            The generated RESTObject.
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabGetError: If the server cannot perform the request
+        """
+        if isinstance(id, str):
+            id = utils.EncodedId(id)
+        path = f"{self.path}/{id}"
+        if lazy is True:
+            if TYPE_CHECKING:
+                assert self._obj_cls._id_attr is not None
+            return self._obj_cls(self, {self._obj_cls._id_attr: id}, lazy=lazy)
+        server_data = self.gitlab.http_get(path, **kwargs)
+        if TYPE_CHECKING:
+            assert not isinstance(server_data, requests.Response)
+        return self._obj_cls(self, server_data, lazy=lazy)
+
+
+class GetWithoutIdMixin(HeadMixin[base.TObjCls]):
+    _optional_get_attrs: tuple[str, ...] = ()
+
+    @exc.on_http_error(exc.GitlabGetError)
+    def get(self, **kwargs: Any) -> base.TObjCls:
+        """Retrieve a single object.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Returns:
+            The generated RESTObject
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabGetError: If the server cannot perform the request
+        """
+        server_data = self.gitlab.http_get(self.path, **kwargs)
+        if TYPE_CHECKING:
+            assert not isinstance(server_data, requests.Response)
+        return self._obj_cls(self, server_data)
+
+
+class RefreshMixin(_RestObjectBase):
+    _id_attr: str | None
+    _attrs: dict[str, Any]
+    _module: ModuleType
+    _parent_attrs: dict[str, Any]
+    _updated_attrs: dict[str, Any]
+    manager: base.RESTManager[Any]
+
+    @exc.on_http_error(exc.GitlabGetError)
+    def refresh(self, **kwargs: Any) -> None:
+        """Refresh a single object from server.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Returns None (updates the object)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabGetError: If the server cannot perform the request
+        """
+        if self._id_attr:
+            path = f"{self.manager.path}/{self.encoded_id}"
+        else:
+            if TYPE_CHECKING:
+                assert self.manager.path is not None
+            path = self.manager.path
+        server_data = self.manager.gitlab.http_get(path, **kwargs)
+        if TYPE_CHECKING:
+            assert not isinstance(server_data, requests.Response)
+        self._update_attrs(server_data)
+
+
+class ListMixin(HeadMixin[base.TObjCls]):
+    _list_filters: tuple[str, ...] = ()
+
+    @overload
+    def list(
+        self, *, iterator: Literal[False] = False, **kwargs: Any
+    ) -> list[base.TObjCls]: ...
+
+    @overload
+    def list(
+        self, *, iterator: Literal[True] = True, **kwargs: Any
+    ) -> base.RESTObjectList[base.TObjCls]: ...
+
+    @overload
+    def list(
+        self, *, iterator: bool = False, **kwargs: Any
+    ) -> base.RESTObjectList[base.TObjCls] | list[base.TObjCls]: ...
+
+    @exc.on_http_error(exc.GitlabListError)
+    def list(
+        self, *, iterator: bool = False, **kwargs: Any
+    ) -> base.RESTObjectList[base.TObjCls] | list[base.TObjCls]:
+        """Retrieve a list of objects.
+
+        Args:
+            get_all: If True, return all the items, without pagination
+            per_page: Number of items to retrieve per request
+            page: ID of the page to return (starts with page 1)
+            iterator: If set to True and no pagination option is
+                defined, return a generator instead of a list
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Returns:
+            The list of objects, or a generator if `iterator` is True
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabListError: If the server cannot perform the request
+        """
+
+        data, _ = utils._transform_types(
+            data=kwargs,
+            custom_types=self._types,
+            transform_data=True,
+            transform_files=False,
+        )
+
+        if self.gitlab.per_page:
+            data.setdefault("per_page", self.gitlab.per_page)
+
+        # global keyset pagination
+        if self.gitlab.pagination:
+            data.setdefault("pagination", self.gitlab.pagination)
+
+        if self.gitlab.order_by:
+            data.setdefault("order_by", self.gitlab.order_by)
+
+        # Allow to overwrite the path, handy for custom listings
+        path = data.pop("path", self.path)
+
+        obj = self.gitlab.http_list(path, iterator=iterator, **data)
+        if isinstance(obj, list):
+            return [self._obj_cls(self, item, created_from_list=True) for item in obj]
+        return base.RESTObjectList(self, self._obj_cls, obj)
+
+
+class RetrieveMixin(ListMixin[base.TObjCls], GetMixin[base.TObjCls]): ...
+
+
+class CreateMixin(base.RESTManager[base.TObjCls]):
+    @exc.on_http_error(exc.GitlabCreateError)
+    def create(self, data: dict[str, Any] | None = None, **kwargs: Any) -> base.TObjCls:
+        """Create a new object.
+
+        Args:
+            data: parameters to send to the server to create the
+                         resource
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Returns:
+            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
+        """
+        if data is None:
+            data = {}
+
+        self._create_attrs.validate_attrs(data=data)
+        data, files = utils._transform_types(
+            data=data, custom_types=self._types, transform_data=False
+        )
+
+        # Handle specific URL for creation
+        path = kwargs.pop("path", self.path)
+        server_data = self.gitlab.http_post(path, post_data=data, files=files, **kwargs)
+        if TYPE_CHECKING:
+            assert not isinstance(server_data, requests.Response)
+        return self._obj_cls(self, server_data)
+
+
+@enum.unique
+class UpdateMethod(enum.IntEnum):
+    PUT = 1
+    POST = 2
+    PATCH = 3
+
+
+class UpdateMixin(base.RESTManager[base.TObjCls]):
+    # Update mixins attrs for easier implementation
+    _update_method: UpdateMethod = UpdateMethod.PUT
+
+    def _get_update_method(self) -> Callable[..., dict[str, Any] | requests.Response]:
+        """Return the HTTP method to use.
+
+        Returns:
+            http_put (default) or http_post
+        """
+        if self._update_method is UpdateMethod.POST:
+            http_method = self.gitlab.http_post
+        elif self._update_method is UpdateMethod.PATCH:
+            # only patch uses required kwargs, so our types are a bit misaligned
+            http_method = self.gitlab.http_patch  # type: ignore[assignment]
+        else:
+            http_method = self.gitlab.http_put
+        return http_method
+
+    @exc.on_http_error(exc.GitlabUpdateError)
+    def update(
+        self,
+        id: str | int | None = None,
+        new_data: dict[str, Any] | None = None,
+        **kwargs: Any,
+    ) -> dict[str, Any]:
+        """Update an object on the server.
+
+        Args:
+            id: ID of the object to update (can be None if not required)
+            new_data: the update data for the object
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Returns:
+            The new object data (*not* a RESTObject)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabUpdateError: If the server cannot perform the request
+        """
+        new_data = new_data or {}
+
+        if id is None:
+            path = self.path
+        else:
+            path = f"{self.path}/{utils.EncodedId(id)}"
+
+        excludes = []
+        if self._obj_cls is not None and self._obj_cls._id_attr is not None:
+            excludes = [self._obj_cls._id_attr]
+        self._update_attrs.validate_attrs(data=new_data, excludes=excludes)
+        new_data, files = utils._transform_types(
+            data=new_data, custom_types=self._types, transform_data=False
+        )
+
+        http_method = self._get_update_method()
+        result = http_method(path, post_data=new_data, files=files, **kwargs)
+        if TYPE_CHECKING:
+            assert not isinstance(result, requests.Response)
+        return result
+
+
+class SetMixin(base.RESTManager[base.TObjCls]):
+    @exc.on_http_error(exc.GitlabSetError)
+    def set(self, key: str, value: str, **kwargs: Any) -> base.TObjCls:
+        """Create or update the object.
+
+        Args:
+            key: The key of the object to create/update
+            value: The value to set for the object
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabSetError: If an error occurred
+
+        Returns:
+            The created/updated attribute
+        """
+        path = f"{self.path}/{utils.EncodedId(key)}"
+        data = {"value": value}
+        server_data = self.gitlab.http_put(path, post_data=data, **kwargs)
+        if TYPE_CHECKING:
+            assert not isinstance(server_data, requests.Response)
+        return self._obj_cls(self, server_data)
+
+
+class DeleteMixin(base.RESTManager[base.TObjCls]):
+    @exc.on_http_error(exc.GitlabDeleteError)
+    def delete(self, id: str | int | None = None, **kwargs: Any) -> None:
+        """Delete an object on the server.
+
+        Args:
+            id: ID of the object to delete
+            **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
+        """
+        if id is None:
+            path = self.path
+        else:
+            path = f"{self.path}/{utils.EncodedId(id)}"
+
+        self.gitlab.http_delete(path, **kwargs)
+
+
+class CRUDMixin(
+    GetMixin[base.TObjCls],
+    ListMixin[base.TObjCls],
+    CreateMixin[base.TObjCls],
+    UpdateMixin[base.TObjCls],
+    DeleteMixin[base.TObjCls],
+): ...
+
+
+class NoUpdateMixin(
+    GetMixin[base.TObjCls],
+    ListMixin[base.TObjCls],
+    CreateMixin[base.TObjCls],
+    DeleteMixin[base.TObjCls],
+): ...
+
+
+class SaveMixin(_RestObjectBase):
+    """Mixin for RESTObject's that can be updated."""
+
+    _id_attr: str | None
+    _attrs: dict[str, Any]
+    _module: ModuleType
+    _parent_attrs: dict[str, Any]
+    _updated_attrs: dict[str, Any]
+    manager: base.RESTManager[Any]
+
+    def _get_updated_data(self) -> dict[str, Any]:
+        updated_data = {}
+        for attr in self.manager._update_attrs.required:
+            # Get everything required, no matter if it's been updated
+            updated_data[attr] = getattr(self, attr)
+        # Add the updated attributes
+        updated_data.update(self._updated_attrs)
+
+        return updated_data
+
+    def save(self, **kwargs: Any) -> dict[str, Any] | None:
+        """Save 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)
+
+        Returns:
+            The new object data (*not* a RESTObject)
+
+        Raise:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabUpdateError: If the server cannot perform the request
+        """
+        updated_data = self._get_updated_data()
+        # Nothing to update. Server fails if sent an empty dict.
+        if not updated_data:
+            return None
+
+        # call the manager
+        obj_id = self.encoded_id
+        if TYPE_CHECKING:
+            assert isinstance(self.manager, UpdateMixin)
+        server_data = self.manager.update(obj_id, updated_data, **kwargs)
+        self._update_attrs(server_data)
+        return server_data
+
+
+class ObjectDeleteMixin(_RestObjectBase):
+    """Mixin for RESTObject's that can be deleted."""
+
+    _id_attr: str | None
+    _attrs: dict[str, Any]
+    _module: ModuleType
+    _parent_attrs: dict[str, Any]
+    _updated_attrs: dict[str, Any]
+    manager: base.RESTManager[Any]
+
+    def delete(self, **kwargs: Any) -> None:
+        """Delete the object from the server.
+
+        Args:
+            **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
+        """
+        if TYPE_CHECKING:
+            assert isinstance(self.manager, DeleteMixin)
+            assert self.encoded_id is not None
+        self.manager.delete(self.encoded_id, **kwargs)
+
+
+class UserAgentDetailMixin(_RestObjectBase):
+    _id_attr: str | None
+    _attrs: dict[str, Any]
+    _module: ModuleType
+    _parent_attrs: dict[str, Any]
+    _updated_attrs: dict[str, Any]
+    manager: base.RESTManager[Any]
+
+    @cli.register_custom_action(cls_names=("Snippet", "ProjectSnippet", "ProjectIssue"))
+    @exc.on_http_error(exc.GitlabGetError)
+    def user_agent_detail(self, **kwargs: Any) -> dict[str, Any]:
+        """Get the user agent detail.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabGetError: If the server cannot perform the request
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/user_agent_detail"
+        result = self.manager.gitlab.http_get(path, **kwargs)
+        if TYPE_CHECKING:
+            assert not isinstance(result, requests.Response)
+        return result
+
+
+class AccessRequestMixin(_RestObjectBase):
+    _id_attr: str | None
+    _attrs: dict[str, Any]
+    _module: ModuleType
+    _parent_attrs: dict[str, Any]
+    _updated_attrs: dict[str, Any]
+    manager: base.RESTManager[Any]
+
+    @cli.register_custom_action(
+        cls_names=("ProjectAccessRequest", "GroupAccessRequest"),
+        optional=("access_level",),
+    )
+    @exc.on_http_error(exc.GitlabUpdateError)
+    def approve(
+        self, access_level: int = gitlab.const.DEVELOPER_ACCESS, **kwargs: Any
+    ) -> None:
+        """Approve an access request.
+
+        Args:
+            access_level: The access level for the user
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabUpdateError: If the server fails to perform the request
+        """
+
+        path = f"{self.manager.path}/{self.encoded_id}/approve"
+        data = {"access_level": access_level}
+        server_data = self.manager.gitlab.http_put(path, post_data=data, **kwargs)
+        if TYPE_CHECKING:
+            assert not isinstance(server_data, requests.Response)
+        self._update_attrs(server_data)
+
+
+class DownloadMixin(_RestObjectBase):
+    _id_attr: str | None
+    _attrs: dict[str, Any]
+    _module: ModuleType
+    _parent_attrs: dict[str, Any]
+    _updated_attrs: dict[str, Any]
+    manager: base.RESTManager[Any]
+
+    @overload
+    def download(
+        self,
+        streamed: Literal[False] = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> bytes: ...
+
+    @overload
+    def download(
+        self,
+        streamed: bool = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[True] = True,
+        **kwargs: Any,
+    ) -> Iterator[Any]: ...
+
+    @overload
+    def download(
+        self,
+        streamed: Literal[True] = True,
+        action: Callable[[bytes], Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> None: ...
+
+    @cli.register_custom_action(cls_names=("GroupExport", "ProjectExport"))
+    @exc.on_http_error(exc.GitlabGetError)
+    def download(
+        self,
+        streamed: bool = False,
+        action: Callable[[bytes], Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: bool = False,
+        **kwargs: Any,
+    ) -> bytes | Iterator[Any] | None:
+        """Download the archive of a resource export.
+
+        Args:
+            streamed: If True the data will be processed by chunks of
+                `chunk_size` and each chunk is passed to `action` for
+                treatment
+            iterator: If True directly return the underlying response
+                iterator
+            action: Callable responsible of dealing with chunk of
+                data
+            chunk_size: 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:
+            The blob content if streamed is False, None otherwise
+        """
+        path = f"{self.manager.path}/download"
+        result = self.manager.gitlab.http_get(
+            path, streamed=streamed, raw=True, **kwargs
+        )
+        if TYPE_CHECKING:
+            assert isinstance(result, requests.Response)
+        return utils.response_content(
+            result, streamed, action, chunk_size, iterator=iterator
+        )
+
+
+class RotateMixin(base.RESTManager[base.TObjCls]):
+    @cli.register_custom_action(
+        cls_names=(
+            "PersonalAccessTokenManager",
+            "GroupAccessTokenManager",
+            "ProjectAccessTokenManager",
+        ),
+        optional=("expires_at",),
+    )
+    @exc.on_http_error(exc.GitlabRotateError)
+    def rotate(
+        self, id: str | int, expires_at: str | None = None, **kwargs: Any
+    ) -> dict[str, Any]:
+        """Rotate an access token.
+
+        Args:
+            id: ID of the token to rotate
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabRotateError: If the server cannot perform the request
+        """
+        path = f"{self.path}/{utils.EncodedId(id)}/rotate"
+        data: dict[str, Any] = {}
+        if expires_at is not None:
+            data = {"expires_at": expires_at}
+
+        server_data = self.gitlab.http_post(path, post_data=data, **kwargs)
+        if TYPE_CHECKING:
+            assert not isinstance(server_data, requests.Response)
+        return server_data
+
+
+class ObjectRotateMixin(_RestObjectBase):
+    _id_attr: str | None
+    _attrs: dict[str, Any]
+    _module: ModuleType
+    _parent_attrs: dict[str, Any]
+    _updated_attrs: dict[str, Any]
+    manager: base.RESTManager[Any]
+
+    @cli.register_custom_action(
+        cls_names=("PersonalAccessToken", "GroupAccessToken", "ProjectAccessToken"),
+        optional=("expires_at",),
+    )
+    @exc.on_http_error(exc.GitlabRotateError)
+    def rotate(self, *, self_rotate: bool = False, **kwargs: Any) -> dict[str, Any]:
+        """Rotate the current access token object.
+
+        Args:
+            self_rotate: If True, the current access token object will be rotated.
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabRotateError: If the server cannot perform the request
+        """
+        if TYPE_CHECKING:
+            assert isinstance(self.manager, RotateMixin)
+            assert self.encoded_id is not None
+        token_id = "self" if self_rotate else self.encoded_id
+        server_data = self.manager.rotate(token_id, **kwargs)
+        self._update_attrs(server_data)
+        return server_data
+
+
+class SubscribableMixin(_RestObjectBase):
+    _id_attr: str | None
+    _attrs: dict[str, Any]
+    _module: ModuleType
+    _parent_attrs: dict[str, Any]
+    _updated_attrs: dict[str, Any]
+    manager: base.RESTManager[Any]
+
+    @cli.register_custom_action(
+        cls_names=("ProjectIssue", "ProjectMergeRequest", "ProjectLabel", "GroupLabel")
+    )
+    @exc.on_http_error(exc.GitlabSubscribeError)
+    def subscribe(self, **kwargs: Any) -> None:
+        """Subscribe to the object notifications.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabSubscribeError: If the subscription cannot be done
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/subscribe"
+        server_data = self.manager.gitlab.http_post(path, **kwargs)
+        if TYPE_CHECKING:
+            assert not isinstance(server_data, requests.Response)
+        self._update_attrs(server_data)
+
+    @cli.register_custom_action(
+        cls_names=("ProjectIssue", "ProjectMergeRequest", "ProjectLabel", "GroupLabel")
+    )
+    @exc.on_http_error(exc.GitlabUnsubscribeError)
+    def unsubscribe(self, **kwargs: Any) -> None:
+        """Unsubscribe from the object notifications.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabUnsubscribeError: If the unsubscription cannot be done
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/unsubscribe"
+        server_data = self.manager.gitlab.http_post(path, **kwargs)
+        if TYPE_CHECKING:
+            assert not isinstance(server_data, requests.Response)
+        self._update_attrs(server_data)
+
+
+class TodoMixin(_RestObjectBase):
+    _id_attr: str | None
+    _attrs: dict[str, Any]
+    _module: ModuleType
+    _parent_attrs: dict[str, Any]
+    _updated_attrs: dict[str, Any]
+    manager: base.RESTManager[Any]
+
+    @cli.register_custom_action(cls_names=("ProjectIssue", "ProjectMergeRequest"))
+    @exc.on_http_error(exc.GitlabTodoError)
+    def todo(self, **kwargs: Any) -> None:
+        """Create a todo associated to the object.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabTodoError: If the todo cannot be set
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/todo"
+        self.manager.gitlab.http_post(path, **kwargs)
+
+
+class TimeTrackingMixin(_RestObjectBase):
+    _id_attr: str | None
+    _attrs: dict[str, Any]
+    _module: ModuleType
+    _parent_attrs: dict[str, Any]
+    _updated_attrs: dict[str, Any]
+    manager: base.RESTManager[Any]
+
+    @cli.register_custom_action(cls_names=("ProjectIssue", "ProjectMergeRequest"))
+    @exc.on_http_error(exc.GitlabTimeTrackingError)
+    def time_stats(self, **kwargs: Any) -> dict[str, Any]:
+        """Get time stats for the object.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabTimeTrackingError: If the time tracking update cannot be done
+        """
+        # Use the existing time_stats attribute if it exist, otherwise make an
+        # API call
+        if "time_stats" in self.attributes:
+            time_stats = self.attributes["time_stats"]
+            if TYPE_CHECKING:
+                assert isinstance(time_stats, dict)
+            return time_stats
+
+        path = f"{self.manager.path}/{self.encoded_id}/time_stats"
+        result = self.manager.gitlab.http_get(path, **kwargs)
+        if TYPE_CHECKING:
+            assert not isinstance(result, requests.Response)
+        return result
+
+    @cli.register_custom_action(
+        cls_names=("ProjectIssue", "ProjectMergeRequest"), required=("duration",)
+    )
+    @exc.on_http_error(exc.GitlabTimeTrackingError)
+    def time_estimate(self, duration: str, **kwargs: Any) -> dict[str, Any]:
+        """Set an estimated time of work for the object.
+
+        Args:
+            duration: Duration in human format (e.g. 3h30)
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabTimeTrackingError: If the time tracking update cannot be done
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/time_estimate"
+        data = {"duration": duration}
+        result = self.manager.gitlab.http_post(path, post_data=data, **kwargs)
+        if TYPE_CHECKING:
+            assert not isinstance(result, requests.Response)
+        return result
+
+    @cli.register_custom_action(cls_names=("ProjectIssue", "ProjectMergeRequest"))
+    @exc.on_http_error(exc.GitlabTimeTrackingError)
+    def reset_time_estimate(self, **kwargs: Any) -> dict[str, Any]:
+        """Resets estimated time for the object to 0 seconds.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabTimeTrackingError: If the time tracking update cannot be done
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/reset_time_estimate"
+        result = self.manager.gitlab.http_post(path, **kwargs)
+        if TYPE_CHECKING:
+            assert not isinstance(result, requests.Response)
+        return result
+
+    @cli.register_custom_action(
+        cls_names=("ProjectIssue", "ProjectMergeRequest"), required=("duration",)
+    )
+    @exc.on_http_error(exc.GitlabTimeTrackingError)
+    def add_spent_time(self, duration: str, **kwargs: Any) -> dict[str, Any]:
+        """Add time spent working on the object.
+
+        Args:
+            duration: Duration in human format (e.g. 3h30)
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabTimeTrackingError: If the time tracking update cannot be done
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/add_spent_time"
+        data = {"duration": duration}
+        result = self.manager.gitlab.http_post(path, post_data=data, **kwargs)
+        if TYPE_CHECKING:
+            assert not isinstance(result, requests.Response)
+        return result
+
+    @cli.register_custom_action(cls_names=("ProjectIssue", "ProjectMergeRequest"))
+    @exc.on_http_error(exc.GitlabTimeTrackingError)
+    def reset_spent_time(self, **kwargs: Any) -> dict[str, Any]:
+        """Resets the time spent working on the object.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabTimeTrackingError: If the time tracking update cannot be done
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/reset_spent_time"
+        result = self.manager.gitlab.http_post(path, **kwargs)
+        if TYPE_CHECKING:
+            assert not isinstance(result, requests.Response)
+        return result
+
+
+class ParticipantsMixin(_RestObjectBase):
+    _id_attr: str | None
+    _attrs: dict[str, Any]
+    _module: ModuleType
+    _parent_attrs: dict[str, Any]
+    _updated_attrs: dict[str, Any]
+    manager: base.RESTManager[Any]
+
+    @cli.register_custom_action(cls_names=("ProjectMergeRequest", "ProjectIssue"))
+    @exc.on_http_error(exc.GitlabListError)
+    def participants(
+        self, **kwargs: Any
+    ) -> gitlab.client.GitlabList | list[dict[str, Any]]:
+        """List the participants.
+
+        Args:
+            get_all: If True, return all the items, without pagination
+            per_page: Number of items to retrieve per request
+            page: ID of the page to return (starts with page 1)
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabListError: If the list could not be retrieved
+
+        Returns:
+            The list of participants
+        """
+
+        path = f"{self.manager.path}/{self.encoded_id}/participants"
+        result = self.manager.gitlab.http_list(path, **kwargs)
+        if TYPE_CHECKING:
+            assert not isinstance(result, requests.Response)
+        return result
+
+
+class BadgeRenderMixin(base.RESTManager[base.TObjCls]):
+    @cli.register_custom_action(
+        cls_names=("GroupBadgeManager", "ProjectBadgeManager"),
+        required=("link_url", "image_url"),
+    )
+    @exc.on_http_error(exc.GitlabRenderError)
+    def render(self, link_url: str, image_url: str, **kwargs: Any) -> dict[str, Any]:
+        """Preview link_url and image_url after interpolation.
+
+        Args:
+            link_url: URL of the badge link
+            image_url: URL of the badge image
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabRenderError: If the rendering failed
+
+        Returns:
+            The rendering properties
+        """
+        path = f"{self.path}/render"
+        data = {"link_url": link_url, "image_url": image_url}
+        result = self.gitlab.http_get(path, data, **kwargs)
+        if TYPE_CHECKING:
+            assert not isinstance(result, requests.Response)
+        return result
+
+
+class PromoteMixin(_RestObjectBase):
+    _id_attr: str | None
+    _attrs: dict[str, Any]
+    _module: ModuleType
+    _parent_attrs: dict[str, Any]
+    _updated_attrs: dict[str, Any]
+    _update_method: UpdateMethod = UpdateMethod.PUT
+    manager: base.RESTManager[Any]
+
+    def _get_update_method(self) -> Callable[..., dict[str, Any] | requests.Response]:
+        """Return the HTTP method to use.
+
+        Returns:
+            http_put (default) or http_post
+        """
+        if self._update_method is UpdateMethod.POST:
+            http_method = self.manager.gitlab.http_post
+        else:
+            http_method = self.manager.gitlab.http_put
+        return http_method
+
+    @exc.on_http_error(exc.GitlabPromoteError)
+    def promote(self, **kwargs: Any) -> dict[str, Any]:
+        """Promote the item.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabPromoteError: If the item could not be promoted
+            GitlabParsingError: If the json data could not be parsed
+
+        Returns:
+            The updated object data (*not* a RESTObject)
+        """
+
+        path = f"{self.manager.path}/{self.encoded_id}/promote"
+        http_method = self._get_update_method()
+        result = http_method(path, **kwargs)
+        if TYPE_CHECKING:
+            assert not isinstance(result, requests.Response)
+        return result
+
+
+class UploadMixin(_RestObjectBase):
+    _id_attr: str | None
+    _attrs: dict[str, Any]
+    _module: ModuleType
+    _parent_attrs: dict[str, Any]
+    _updated_attrs: dict[str, Any]
+    _upload_path: str
+    manager: base.RESTManager[Any]
+
+    def _get_upload_path(self) -> str:
+        """Formats _upload_path with object attributes.
+
+        Returns:
+            The upload path
+        """
+        if TYPE_CHECKING:
+            assert isinstance(self._upload_path, str)
+        data = self.attributes
+        return self._upload_path.format(**data)
+
+    @cli.register_custom_action(
+        cls_names=("Project", "ProjectWiki"), required=("filename", "filepath")
+    )
+    @exc.on_http_error(exc.GitlabUploadError)
+    def upload(
+        self,
+        filename: str,
+        filedata: bytes | None = None,
+        filepath: str | None = None,
+        **kwargs: Any,
+    ) -> dict[str, Any]:
+        """Upload the specified file.
+
+        .. note::
+
+            Either ``filedata`` or ``filepath`` *MUST* be specified.
+
+        Args:
+            filename: The name of the file being uploaded
+            filedata: The raw data of the file being uploaded
+            filepath: The path to a local file to upload (optional)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabUploadError: If the file upload fails
+            GitlabUploadError: If ``filedata`` and ``filepath`` are not
+                specified
+            GitlabUploadError: If both ``filedata`` and ``filepath`` are
+                specified
+
+        Returns:
+            A ``dict`` with info on the uploaded file
+        """
+        if filepath is None and filedata is None:
+            raise exc.GitlabUploadError("No file contents or path specified")
+
+        if filedata is not None and filepath is not None:
+            raise exc.GitlabUploadError("File contents and file path specified")
+
+        if filepath is not None:
+            with open(filepath, "rb") as f:
+                filedata = f.read()
+
+        file_info = {"file": (filename, filedata)}
+        path = self._get_upload_path()
+        server_data = self.manager.gitlab.http_post(path, files=file_info, **kwargs)
+
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+        return server_data
diff --git a/gitlab/objects.py b/gitlab/objects.py
deleted file mode 100644
index 2a33dc518..000000000
--- a/gitlab/objects.py
+++ /dev/null
@@ -1,2629 +0,0 @@
-# -*- coding: utf-8 -*-
-#
-# Copyright (C) 2013-2015 Gauvain Pocentek <gauvain@pocentek.net>
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Lesser General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Lesser General Public License for more details.
-#
-# You should have received a copy of the GNU Lesser General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from __future__ import print_function
-from __future__ import division
-from __future__ import absolute_import
-import base64
-import copy
-import itertools
-import json
-import sys
-import warnings
-
-import six
-
-import gitlab
-from gitlab.exceptions import *  # noqa
-from gitlab import utils
-
-
-class jsonEncoder(json.JSONEncoder):
-    def default(self, obj):
-        if isinstance(obj, GitlabObject):
-            return obj.as_dict()
-        elif isinstance(obj, gitlab.Gitlab):
-            return {'url': obj._url}
-        return json.JSONEncoder.default(self, obj)
-
-
-class BaseManager(object):
-    """Base manager class for API operations.
-
-    Managers provide method to manage GitLab API objects, such as retrieval,
-    listing, creation.
-
-    Inherited class must define the ``obj_cls`` attribute.
-
-    Attributes:
-        obj_cls (class): class of objects wrapped by this manager.
-    """
-
-    obj_cls = None
-
-    def __init__(self, gl, parent=None, args=[]):
-        """Constructs a manager.
-
-        Args:
-            gl (gitlab.Gitlab): Gitlab object referencing the GitLab server.
-            parent (Optional[Manager]): A parent manager.
-            args (list): A list of tuples defining a link between the
-                parent/child attributes.
-
-        Raises:
-            AttributeError: If `obj_cls` is None.
-        """
-        self.gitlab = gl
-        self.args = args
-        self.parent = parent
-
-        if self.obj_cls is None:
-            raise AttributeError("obj_cls must be defined")
-
-    def _set_parent_args(self, **kwargs):
-        args = copy.copy(kwargs)
-        if self.parent is not None:
-            for attr, parent_attr in self.args:
-                args.setdefault(attr, getattr(self.parent, parent_attr))
-
-        return args
-
-    def get(self, id=None, **kwargs):
-        """Get a GitLab object.
-
-        Args:
-            id: ID of the object to retrieve.
-            **kwargs: Additional arguments to send to GitLab.
-
-        Returns:
-            object: An object of class `obj_cls`.
-
-        Raises:
-            NotImplementedError: If objects cannot be retrieved.
-            GitlabGetError: If the server fails to perform the request.
-        """
-        args = self._set_parent_args(**kwargs)
-        if not self.obj_cls.canGet:
-            raise NotImplementedError
-        return self.obj_cls.get(self.gitlab, id, **args)
-
-    def list(self, **kwargs):
-        """Get a list of GitLab objects.
-
-        Args:
-            **kwargs: Additional arguments to send to GitLab.
-
-        Returns:
-            list[object]: A list of `obj_cls` objects.
-
-        Raises:
-            NotImplementedError: If objects cannot be listed.
-            GitlabListError: If the server fails to perform the request.
-        """
-        args = self._set_parent_args(**kwargs)
-        if not self.obj_cls.canList:
-            raise NotImplementedError
-        return self.obj_cls.list(self.gitlab, **args)
-
-    def create(self, data, **kwargs):
-        """Create a new object of class `obj_cls`.
-
-        Args:
-            data (dict): The parameters to send to the GitLab server to create
-                the object. Required and optional arguments are defined in the
-                `requiredCreateAttrs` and `optionalCreateAttrs` of the
-                `obj_cls` class.
-            **kwargs: Additional arguments to send to GitLab.
-
-        Returns:
-            object: A newly create `obj_cls` object.
-
-        Raises:
-            NotImplementedError: If objects cannot be created.
-            GitlabCreateError: If the server fails to perform the request.
-        """
-        args = self._set_parent_args(**kwargs)
-        if not self.obj_cls.canCreate:
-            raise NotImplementedError
-        return self.obj_cls.create(self.gitlab, data, **args)
-
-    def delete(self, id, **kwargs):
-        """Delete a GitLab object.
-
-        Args:
-            id: ID of the object to delete.
-
-        Raises:
-            NotImplementedError: If objects cannot be deleted.
-            GitlabDeleteError: If the server fails to perform the request.
-        """
-        args = self._set_parent_args(**kwargs)
-        if not self.obj_cls.canDelete:
-            raise NotImplementedError
-        self.gitlab.delete(self.obj_cls, id, **args)
-
-
-class GitlabObject(object):
-    """Base class for all classes that interface with GitLab."""
-    #: Url to use in GitLab for this object
-    _url = None
-    # Some objects (e.g. merge requests) have different urls for singular and
-    # plural
-    _urlPlural = None
-    _id_in_delete_url = True
-    _id_in_update_url = True
-    _constructorTypes = None
-
-    #: Tells if GitLab-api allows retrieving single objects.
-    canGet = True
-    #: Tells if GitLab-api allows listing of objects.
-    canList = True
-    #: Tells if GitLab-api allows creation of new objects.
-    canCreate = True
-    #: Tells if GitLab-api allows updating object.
-    canUpdate = True
-    #: Tells if GitLab-api allows deleting object.
-    canDelete = True
-    #: Attributes that are required for constructing url.
-    requiredUrlAttrs = []
-    #: Attributes that are required when retrieving list of objects.
-    requiredListAttrs = []
-    #: Attributes that are optional when retrieving list of objects.
-    optionalListAttrs = []
-    #: Attributes that are optional when retrieving single object.
-    optionalGetAttrs = []
-    #: Attributes that are required when retrieving single object.
-    requiredGetAttrs = []
-    #: Attributes that are required when deleting object.
-    requiredDeleteAttrs = []
-    #: Attributes that are required when creating a new object.
-    requiredCreateAttrs = []
-    #: Attributes that are optional when creating a new object.
-    optionalCreateAttrs = []
-    #: Attributes that are required when updating an object.
-    requiredUpdateAttrs = []
-    #: Attributes that are optional when updating an object.
-    optionalUpdateAttrs = []
-    #: Whether the object ID is required in the GET url.
-    getRequiresId = True
-    #: List of managers to create.
-    managers = []
-    #: Name of the identifier of an object.
-    idAttr = 'id'
-    #: Attribute to use as ID when displaying the object.
-    shortPrintAttr = None
-
-    def _data_for_gitlab(self, extra_parameters={}, update=False,
-                         as_json=True):
-        data = {}
-        if update and (self.requiredUpdateAttrs or self.optionalUpdateAttrs):
-            attributes = itertools.chain(self.requiredUpdateAttrs,
-                                         self.optionalUpdateAttrs)
-        else:
-            attributes = itertools.chain(self.requiredCreateAttrs,
-                                         self.optionalCreateAttrs)
-        attributes = list(attributes) + ['sudo', 'page', 'per_page']
-        for attribute in attributes:
-            if hasattr(self, attribute):
-                value = getattr(self, attribute)
-                if isinstance(value, list):
-                    value = ",".join(value)
-                if attribute == 'sudo':
-                    value = str(value)
-                data[attribute] = value
-
-        data.update(extra_parameters)
-
-        return json.dumps(data) if as_json else data
-
-    @classmethod
-    def list(cls, gl, **kwargs):
-        """Retrieve a list of objects from GitLab.
-
-        Args:
-            gl (gitlab.Gitlab): Gitlab object referencing the GitLab server.
-            per_page (int): Maximum number of items to return.
-            page (int): ID of the page to return when using pagination.
-
-        Returns:
-            list[object]: A list of objects.
-
-        Raises:
-            NotImplementedError: If objects can't be listed.
-            GitlabListError: If the server cannot perform the request.
-        """
-        if not cls.canList:
-            raise NotImplementedError
-
-        if not cls._url:
-            raise NotImplementedError
-
-        return gl.list(cls, **kwargs)
-
-    @classmethod
-    def get(cls, gl, id, **kwargs):
-        """Retrieve a single object.
-
-        Args:
-            gl (gitlab.Gitlab): Gitlab object referencing the GitLab server.
-            id (int or str): ID of the object to retrieve.
-
-        Returns:
-            object: The found GitLab object.
-
-        Raises:
-            NotImplementedError: If objects can't be retrieved.
-            GitlabGetError: If the server cannot perform the request.
-        """
-
-        if cls.canGet is False:
-            raise NotImplementedError
-        elif cls.canGet is True:
-            return cls(gl, id, **kwargs)
-        elif cls.canGet == 'from_list':
-            for obj in cls.list(gl, **kwargs):
-                obj_id = getattr(obj, obj.idAttr)
-                if str(obj_id) == str(id):
-                    return obj
-
-            raise GitlabGetError("Object not found")
-
-    def _get_object(self, k, v, **kwargs):
-        if self._constructorTypes and k in self._constructorTypes:
-            return globals()[self._constructorTypes[k]](self.gitlab, v,
-                                                        **kwargs)
-        else:
-            return v
-
-    def _set_from_dict(self, data, **kwargs):
-        if not hasattr(data, 'items'):
-            return
-
-        for k, v in data.items():
-            if isinstance(v, list):
-                self.__dict__[k] = []
-                for i in v:
-                    self.__dict__[k].append(self._get_object(k, i, **kwargs))
-            elif v is None:
-                self.__dict__[k] = None
-            else:
-                self.__dict__[k] = self._get_object(k, v, **kwargs)
-
-    def _create(self, **kwargs):
-        if not self.canCreate:
-            raise NotImplementedError
-
-        json = self.gitlab.create(self, **kwargs)
-        self._set_from_dict(json)
-        self._from_api = True
-
-    def _update(self, **kwargs):
-        if not self.canUpdate:
-            raise NotImplementedError
-
-        json = self.gitlab.update(self, **kwargs)
-        self._set_from_dict(json)
-
-    def save(self, **kwargs):
-        if self._from_api:
-            self._update(**kwargs)
-        else:
-            self._create(**kwargs)
-
-    def delete(self, **kwargs):
-        if not self.canDelete:
-            raise NotImplementedError
-
-        if not self._from_api:
-            raise GitlabDeleteError("Object not yet created")
-
-        return self.gitlab.delete(self, **kwargs)
-
-    @classmethod
-    def create(cls, gl, data, **kwargs):
-        """Create an object.
-
-        Args:
-            gl (gitlab.Gitlab): Gitlab object referencing the GitLab server.
-            data (dict): The data used to define the object.
-
-        Returns:
-            object: The new object.
-
-        Raises:
-            NotImplementedError: If objects can't be created.
-            GitlabCreateError: If the server cannot perform the request.
-        """
-        if not cls.canCreate:
-            raise NotImplementedError
-
-        obj = cls(gl, data, **kwargs)
-        obj.save()
-
-        return obj
-
-    def __init__(self, gl, data=None, **kwargs):
-        """Constructs a new object.
-
-        Do not use this method. Use the `get` or `create` class methods
-        instead.
-
-        Args:
-            gl (gitlab.Gitlab): Gitlab object referencing the GitLab server.
-            data: If `data` is a dict, create a new object using the
-                information. If it is an int or a string, get a GitLab object
-                from an API request.
-            **kwargs: Additional arguments to send to GitLab.
-        """
-        self._from_api = False
-        #: (gitlab.Gitlab): Gitlab connection.
-        self.gitlab = gl
-
-        if (data is None or isinstance(data, six.integer_types) or
-           isinstance(data, six.string_types)):
-            if not self.canGet:
-                raise NotImplementedError
-            data = self.gitlab.get(self.__class__, data, **kwargs)
-            self._from_api = True
-
-            # the API returned a list because custom kwargs where used
-            # instead of the id to request an object. Usually parameters
-            # other than an id return ambiguous results. However in the
-            # gitlab universe iids together with a project_id are
-            # unambiguous for merge requests and issues, too.
-            # So if there is only one element we can use it as our data
-            # source.
-            if 'iid' in kwargs and isinstance(data, list):
-                if len(data) < 1:
-                    raise GitlabGetError('Not found')
-                elif len(data) == 1:
-                    data = data[0]
-                else:
-                    raise GitlabGetError('Impossible! You found multiple'
-                                         ' elements with the same iid.')
-
-        self._set_from_dict(data, **kwargs)
-
-        if kwargs:
-            for k, v in kwargs.items():
-                # Don't overwrite attributes returned by the server (#171)
-                if k not in self.__dict__ or not self.__dict__[k]:
-                    self.__dict__[k] = v
-
-        # Special handling for api-objects that don't have id-number in api
-        # responses. Currently only Labels and Files
-        if not hasattr(self, "id"):
-            self.id = None
-
-    def _set_manager(self, var, cls, attrs):
-        manager = cls(self.gitlab, self, attrs)
-        setattr(self, var, manager)
-
-    def __getattr__(self, name):
-        # build a manager if it doesn't exist yet
-        for var, cls, attrs in self.managers:
-            if var != name:
-                continue
-            self._set_manager(var, cls, attrs)
-            return getattr(self, var)
-
-        raise AttributeError
-
-    def __str__(self):
-        return '%s => %s' % (type(self), str(self.__dict__))
-
-    def __repr__(self):
-        return '<%s %s:%s>' % (self.__class__.__name__,
-                               self.idAttr,
-                               getattr(self, self.idAttr))
-
-    def display(self, pretty):
-        if pretty:
-            self.pretty_print()
-        else:
-            self.short_print()
-
-    def short_print(self, depth=0):
-        """Print the object on the standard output (verbose).
-
-        Args:
-            depth (int): Used internaly for recursive call.
-        """
-        id = self.__dict__[self.idAttr]
-        print("%s%s: %s" % (" " * depth * 2, self.idAttr, id))
-        if self.shortPrintAttr:
-            print("%s%s: %s" % (" " * depth * 2,
-                                self.shortPrintAttr.replace('_', '-'),
-                                self.__dict__[self.shortPrintAttr]))
-
-    @staticmethod
-    def _get_display_encoding():
-        return sys.stdout.encoding or sys.getdefaultencoding()
-
-    @staticmethod
-    def _obj_to_str(obj):
-        if isinstance(obj, dict):
-            s = ", ".join(["%s: %s" %
-                          (x, GitlabObject._obj_to_str(y))
-                          for (x, y) in obj.items()])
-            return "{ %s }" % s
-        elif isinstance(obj, list):
-            s = ", ".join([GitlabObject._obj_to_str(x) for x in obj])
-            return "[ %s ]" % s
-        elif six.PY2 and isinstance(obj, six.text_type):
-            return obj.encode(GitlabObject._get_display_encoding(), "replace")
-        else:
-            return str(obj)
-
-    def pretty_print(self, depth=0):
-        """Print the object on the standard output (verbose).
-
-        Args:
-            depth (int): Used internaly for recursive call.
-        """
-        id = self.__dict__[self.idAttr]
-        print("%s%s: %s" % (" " * depth * 2, self.idAttr, id))
-        for k in sorted(self.__dict__.keys()):
-            if k in (self.idAttr, 'id', 'gitlab'):
-                continue
-            if k[0] == '_':
-                continue
-            v = self.__dict__[k]
-            pretty_k = k.replace('_', '-')
-            if six.PY2:
-                pretty_k = pretty_k.encode(
-                    GitlabObject._get_display_encoding(), "replace")
-            if isinstance(v, GitlabObject):
-                if depth == 0:
-                    print("%s:" % pretty_k)
-                    v.pretty_print(1)
-                else:
-                    print("%s: %s" % (pretty_k, v.id))
-            elif isinstance(v, BaseManager):
-                continue
-            else:
-                if hasattr(v, __name__) and v.__name__ == 'Gitlab':
-                    continue
-                v = GitlabObject._obj_to_str(v)
-                print("%s%s: %s" % (" " * depth * 2, pretty_k, v))
-
-    def json(self):
-        """Dump the object as json.
-
-        Returns:
-            str: The json string.
-        """
-        return json.dumps(self, cls=jsonEncoder)
-
-    def as_dict(self):
-        """Dump the object as a dict."""
-        return {k: v for k, v in six.iteritems(self.__dict__)
-                if (not isinstance(v, BaseManager) and not k[0] == '_')}
-
-    def __eq__(self, other):
-        if type(other) is type(self):
-            return self.as_dict() == other.as_dict()
-        return False
-
-    def __ne__(self, other):
-        return not self.__eq__(other)
-
-
-class SidekiqManager(object):
-    """Manager for the Sidekiq methods.
-
-    This manager doesn't actually manage objects but provides helper fonction
-    for the sidekiq metrics API.
-    """
-    def __init__(self, gl):
-        """Constructs a Sidekiq manager.
-
-        Args:
-            gl (gitlab.Gitlab): Gitlab object referencing the GitLab server.
-        """
-        self.gitlab = gl
-
-    def _simple_get(self, url, **kwargs):
-        r = self.gitlab._raw_get(url, **kwargs)
-        raise_error_from_response(r, GitlabGetError)
-        return r.json()
-
-    def queue_metrics(self, **kwargs):
-        """Returns the registred queues information."""
-        return self._simple_get('/sidekiq/queue_metrics', **kwargs)
-
-    def process_metrics(self, **kwargs):
-        """Returns the registred sidekiq workers."""
-        return self._simple_get('/sidekiq/process_metrics', **kwargs)
-
-    def job_stats(self, **kwargs):
-        """Returns statistics about the jobs performed."""
-        return self._simple_get('/sidekiq/job_stats', **kwargs)
-
-    def compound_metrics(self, **kwargs):
-        """Returns all available metrics and statistics."""
-        return self._simple_get('/sidekiq/compound_metrics', **kwargs)
-
-
-class UserEmail(GitlabObject):
-    _url = '/users/%(user_id)s/emails'
-    canUpdate = False
-    shortPrintAttr = 'email'
-    requiredUrlAttrs = ['user_id']
-    requiredCreateAttrs = ['email']
-
-
-class UserEmailManager(BaseManager):
-    obj_cls = UserEmail
-
-
-class UserKey(GitlabObject):
-    _url = '/users/%(user_id)s/keys'
-    canGet = 'from_list'
-    canUpdate = False
-    requiredUrlAttrs = ['user_id']
-    requiredCreateAttrs = ['title', 'key']
-
-
-class UserKeyManager(BaseManager):
-    obj_cls = UserKey
-
-
-class UserProject(GitlabObject):
-    _url = '/projects/user/%(user_id)s'
-    _constructorTypes = {'owner': 'User', 'namespace': 'Group'}
-    canUpdate = False
-    canDelete = False
-    canList = False
-    canGet = False
-    requiredUrlAttrs = ['user_id']
-    requiredCreateAttrs = ['name']
-    optionalCreateAttrs = ['default_branch', 'issues_enabled', 'wall_enabled',
-                           'merge_requests_enabled', 'wiki_enabled',
-                           'snippets_enabled', 'public', 'visibility_level',
-                           'description', 'builds_enabled', 'public_builds',
-                           'import_url', 'only_allow_merge_if_build_succeeds']
-
-
-class UserProjectManager(BaseManager):
-    obj_cls = UserProject
-
-
-class User(GitlabObject):
-    _url = '/users'
-    shortPrintAttr = 'username'
-    requiredCreateAttrs = ['email', 'username', 'name', 'password']
-    optionalCreateAttrs = ['skype', 'linkedin', 'twitter', 'projects_limit',
-                           'extern_uid', 'provider', 'bio', 'admin',
-                           'can_create_group', 'website_url', 'confirm',
-                           'external']
-    requiredUpdateAttrs = ['email', 'username', 'name']
-    optionalUpdateAttrs = ['password', 'skype', 'linkedin', 'twitter',
-                           'projects_limit', 'extern_uid', 'provider', 'bio',
-                           'admin', 'can_create_group', 'website_url',
-                           'confirm', 'external']
-    managers = (
-        ('emails', UserEmailManager, [('user_id', 'id')]),
-        ('keys', UserKeyManager, [('user_id', 'id')]),
-        ('projects', UserProjectManager, [('user_id', 'id')]),
-    )
-
-    def _data_for_gitlab(self, extra_parameters={}, update=False,
-                         as_json=True):
-        if hasattr(self, 'confirm'):
-            self.confirm = str(self.confirm).lower()
-        return super(User, self)._data_for_gitlab(extra_parameters)
-
-    def block(self, **kwargs):
-        """Blocks the user."""
-        url = '/users/%s/block' % self.id
-        r = self.gitlab._raw_put(url, **kwargs)
-        raise_error_from_response(r, GitlabBlockError)
-        self.state = 'blocked'
-
-    def unblock(self, **kwargs):
-        """Unblocks the user."""
-        url = '/users/%s/unblock' % self.id
-        r = self.gitlab._raw_put(url, **kwargs)
-        raise_error_from_response(r, GitlabUnblockError)
-        self.state = 'active'
-
-    def __eq__(self, other):
-        if type(other) is type(self):
-            selfdict = self.as_dict()
-            otherdict = other.as_dict()
-            selfdict.pop('password', None)
-            otherdict.pop('password', None)
-            return selfdict == otherdict
-        return False
-
-
-class UserManager(BaseManager):
-    obj_cls = User
-
-    def search(self, query, **kwargs):
-        """Search users.
-
-        Args:
-            query (str): The query string to send to GitLab for the search.
-            all (bool): If True, return all the items, without pagination
-            **kwargs: Additional arguments to send to GitLab.
-
-        Returns:
-            list(User): A list of matching users.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabListError: If the server fails to perform the request.
-        """
-        url = self.obj_cls._url + '?search=' + query
-        return self.gitlab._raw_list(url, self.obj_cls, **kwargs)
-
-    def get_by_username(self, username, **kwargs):
-        """Get a user by its username.
-
-        Args:
-            username (str): The name of the user.
-            **kwargs: Additional arguments to send to GitLab.
-
-        Returns:
-            User: The matching user.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabGetError: If the server fails to perform the request.
-        """
-        url = self.obj_cls._url + '?username=' + username
-        results = self.gitlab._raw_list(url, self.obj_cls, **kwargs)
-        assert len(results) in (0, 1)
-        try:
-            return results[0]
-        except IndexError:
-            raise GitlabGetError('no such user: ' + username)
-
-
-class CurrentUserEmail(GitlabObject):
-    _url = '/user/emails'
-    canUpdate = False
-    shortPrintAttr = 'email'
-    requiredCreateAttrs = ['email']
-
-
-class CurrentUserEmailManager(BaseManager):
-    obj_cls = CurrentUserEmail
-
-
-class CurrentUserKey(GitlabObject):
-    _url = '/user/keys'
-    canUpdate = False
-    shortPrintAttr = 'title'
-    requiredCreateAttrs = ['title', 'key']
-
-
-class CurrentUserKeyManager(BaseManager):
-    obj_cls = CurrentUserKey
-
-
-class CurrentUser(GitlabObject):
-    _url = '/user'
-    canList = False
-    canCreate = False
-    canUpdate = False
-    canDelete = False
-    shortPrintAttr = 'username'
-    managers = (
-        ('emails', CurrentUserEmailManager, [('user_id', 'id')]),
-        ('keys', CurrentUserKeyManager, [('user_id', 'id')]),
-    )
-
-
-class ApplicationSettings(GitlabObject):
-    _url = '/application/settings'
-    _id_in_update_url = False
-    optionalUpdateAttrs = ['after_sign_out_path',
-                           'container_registry_token_expire_delay',
-                           'default_branch_protection',
-                           'default_project_visibility',
-                           'default_projects_limit',
-                           'default_snippet_visibility',
-                           'domain_blacklist',
-                           'domain_blacklist_enabled',
-                           'domain_whitelist',
-                           'enabled_git_access_protocol',
-                           'gravatar_enabled',
-                           'home_page_url',
-                           'max_attachment_size',
-                           'repository_storage',
-                           'restricted_signup_domains',
-                           'restricted_visibility_levels',
-                           'session_expire_delay',
-                           'sign_in_text',
-                           'signin_enabled',
-                           'signup_enabled',
-                           'twitter_sharing_enabled',
-                           'user_oauth_applications']
-    canList = False
-    canCreate = False
-    canDelete = False
-
-
-class ApplicationSettingsManager(BaseManager):
-    obj_cls = ApplicationSettings
-
-
-class BroadcastMessage(GitlabObject):
-    _url = '/broadcast_messages'
-    requiredCreateAttrs = ['message']
-    optionalCreateAttrs = ['starts_at', 'ends_at', 'color', 'font']
-    requiredUpdateAttrs = []
-    optionalUpdateAttrs = ['message', 'starts_at', 'ends_at', 'color', 'font']
-
-
-class BroadcastMessageManager(BaseManager):
-    obj_cls = BroadcastMessage
-
-
-class Key(GitlabObject):
-    _url = '/deploy_keys'
-    canGet = 'from_list'
-    canCreate = False
-    canUpdate = False
-    canDelete = False
-
-
-class KeyManager(BaseManager):
-    obj_cls = Key
-
-
-class NotificationSettings(GitlabObject):
-    _url = '/notification_settings'
-    _id_in_update_url = False
-    optionalUpdateAttrs = ['level',
-                           'notification_email',
-                           'new_note',
-                           'new_issue',
-                           'reopen_issue',
-                           'close_issue',
-                           'reassign_issue',
-                           'new_merge_request',
-                           'reopen_merge_request',
-                           'close_merge_request',
-                           'reassign_merge_request',
-                           'merge_merge_request']
-    canList = False
-    canCreate = False
-    canDelete = False
-
-
-class NotificationSettingsManager(BaseManager):
-    obj_cls = NotificationSettings
-
-
-class Gitignore(GitlabObject):
-    _url = '/templates/gitignores'
-    canDelete = False
-    canUpdate = False
-    canCreate = False
-    idAttr = 'name'
-
-
-class GitignoreManager(BaseManager):
-    obj_cls = Gitignore
-
-
-class Gitlabciyml(GitlabObject):
-    _url = '/templates/gitlab_ci_ymls'
-    canDelete = False
-    canUpdate = False
-    canCreate = False
-    idAttr = 'name'
-
-
-class GitlabciymlManager(BaseManager):
-    obj_cls = Gitlabciyml
-
-
-class GroupIssue(GitlabObject):
-    _url = '/groups/%(group_id)s/issues'
-    canGet = 'from_list'
-    canCreate = False
-    canUpdate = False
-    canDelete = False
-    requiredUrlAttrs = ['group_id']
-    optionalListAttrs = ['state', 'labels', 'milestone', 'order_by', 'sort']
-
-
-class GroupIssueManager(BaseManager):
-    obj_cls = GroupIssue
-
-
-class GroupMember(GitlabObject):
-    _url = '/groups/%(group_id)s/members'
-    canGet = 'from_list'
-    requiredUrlAttrs = ['group_id']
-    requiredCreateAttrs = ['access_level', 'user_id']
-    requiredUpdateAttrs = ['access_level']
-    shortPrintAttr = 'username'
-
-    def _update(self, **kwargs):
-        self.user_id = self.id
-        super(GroupMember, self)._update(**kwargs)
-
-
-class GroupMemberManager(BaseManager):
-    obj_cls = GroupMember
-
-
-class GroupNotificationSettings(NotificationSettings):
-    _url = '/groups/%(group_id)s/notification_settings'
-    requiredUrlAttrs = ['group_id']
-
-
-class GroupNotificationSettingsManager(BaseManager):
-    obj_cls = GroupNotificationSettings
-
-
-class GroupProject(GitlabObject):
-    _url = '/groups/%(group_id)s/projects'
-    canGet = 'from_list'
-    canCreate = False
-    canDelete = False
-    canUpdate = False
-    optionalListAttrs = ['archived', 'visibility', 'order_by', 'sort',
-                         'search', 'ci_enabled_first']
-
-
-class GroupProjectManager(BaseManager):
-    obj_cls = GroupProject
-
-
-class GroupAccessRequest(GitlabObject):
-    _url = '/groups/%(group_id)s/access_requests'
-    canGet = 'from_list'
-    canUpdate = False
-
-    def approve(self, access_level=gitlab.DEVELOPER_ACCESS, **kwargs):
-        """Approve an access request.
-
-        Attrs:
-            access_level (int): The access level for the user.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabUpdateError: If the server fails to perform the request.
-        """
-
-        url = ('/groups/%(group_id)s/access_requests/%(id)s/approve' %
-               {'group_id': self.group_id, 'id': self.id})
-        data = {'access_level': access_level}
-        r = self.gitlab._raw_put(url, data=data, **kwargs)
-        raise_error_from_response(r, GitlabUpdateError, 201)
-        self._set_from_dict(r.json())
-
-
-class GroupAccessRequestManager(BaseManager):
-    obj_cls = GroupAccessRequest
-
-
-class Group(GitlabObject):
-    _url = '/groups'
-    _constructorTypes = {'projects': 'Project'}
-    requiredCreateAttrs = ['name', 'path']
-    optionalCreateAttrs = ['description', 'visibility_level']
-    optionalUpdateAttrs = ['name', 'path', 'description', 'visibility_level']
-    shortPrintAttr = 'name'
-    managers = (
-        ('accessrequests', GroupAccessRequestManager, [('group_id', 'id')]),
-        ('members', GroupMemberManager, [('group_id', 'id')]),
-        ('notificationsettings', GroupNotificationSettingsManager,
-         [('group_id', 'id')]),
-        ('projects', GroupProjectManager, [('group_id', 'id')]),
-        ('issues', GroupIssueManager, [('group_id', 'id')]),
-    )
-
-    GUEST_ACCESS = gitlab.GUEST_ACCESS
-    REPORTER_ACCESS = gitlab.REPORTER_ACCESS
-    DEVELOPER_ACCESS = gitlab.DEVELOPER_ACCESS
-    MASTER_ACCESS = gitlab.MASTER_ACCESS
-    OWNER_ACCESS = gitlab.OWNER_ACCESS
-
-    VISIBILITY_PRIVATE = gitlab.VISIBILITY_PRIVATE
-    VISIBILITY_INTERNAL = gitlab.VISIBILITY_INTERNAL
-    VISIBILITY_PUBLIC = gitlab.VISIBILITY_PUBLIC
-
-    def transfer_project(self, id, **kwargs):
-        """Transfers a project to this new groups.
-
-        Attrs:
-            id (int): ID of the project to transfer.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabTransferProjectError: If the server fails to perform the
-                request.
-        """
-        url = '/groups/%d/projects/%d' % (self.id, id)
-        r = self.gitlab._raw_post(url, None, **kwargs)
-        raise_error_from_response(r, GitlabTransferProjectError, 201)
-
-
-class GroupManager(BaseManager):
-    obj_cls = Group
-
-    def search(self, query, **kwargs):
-        """Searches groups by name.
-
-        Args:
-            query (str): The search string
-            all (bool): If True, return all the items, without pagination
-
-        Returns:
-            list(Group): a list of matching groups.
-        """
-        url = '/groups?search=' + query
-        return self.gitlab._raw_list(url, self.obj_cls, **kwargs)
-
-
-class Hook(GitlabObject):
-    _url = '/hooks'
-    canUpdate = False
-    requiredCreateAttrs = ['url']
-    shortPrintAttr = 'url'
-
-
-class HookManager(BaseManager):
-    obj_cls = Hook
-
-
-class Issue(GitlabObject):
-    _url = '/issues'
-    _constructorTypes = {'author': 'User', 'assignee': 'User',
-                         'milestone': 'ProjectMilestone'}
-    canGet = 'from_list'
-    canDelete = False
-    canUpdate = False
-    canCreate = False
-    shortPrintAttr = 'title'
-    optionalListAttrs = ['state', 'labels', 'order_by', 'sort']
-
-
-class IssueManager(BaseManager):
-    obj_cls = Issue
-
-
-class License(GitlabObject):
-    _url = '/licenses'
-    canDelete = False
-    canUpdate = False
-    canCreate = False
-    idAttr = 'key'
-
-    optionalListAttrs = ['popular']
-    optionalGetAttrs = ['project', 'fullname']
-
-
-class LicenseManager(BaseManager):
-    obj_cls = License
-
-
-class Snippet(GitlabObject):
-    _url = '/snippets'
-    _constructorTypes = {'author': 'User'}
-    requiredCreateAttrs = ['title', 'file_name', 'content']
-    optionalCreateAttrs = ['lifetime', 'visibility_level']
-    optionalUpdateAttrs = ['title', 'file_name', 'content', 'visibility_level']
-    shortPrintAttr = 'title'
-
-    def raw(self, streamed=False, action=None, chunk_size=1024, **kwargs):
-        """Return the raw content of a snippet.
-
-        Args:
-            streamed (bool): If True the data will be processed by chunks of
-                `chunk_size` and each chunk is passed to `action` for
-                treatment.
-            action (callable): Callable responsible of dealing with chunk of
-                data.
-            chunk_size (int): Size of each chunk.
-
-        Returns:
-            str: The snippet content.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabGetError: If the server fails to perform the request.
-        """
-        url = ("/snippets/%(snippet_id)s/raw" % {'snippet_id': self.id})
-        r = self.gitlab._raw_get(url, **kwargs)
-        raise_error_from_response(r, GitlabGetError)
-        return utils.response_content(r, streamed, action, chunk_size)
-
-
-class SnippetManager(BaseManager):
-    obj_cls = Snippet
-
-    def public(self, **kwargs):
-        """List all the public snippets.
-
-        Args:
-            all (bool): If True, return all the items, without pagination
-            **kwargs: Additional arguments to send to GitLab.
-
-        Returns:
-            list(gitlab.Gitlab.Snippet): The list of snippets.
-        """
-        return self.gitlab._raw_list("/snippets/public", Snippet, **kwargs)
-
-
-class Namespace(GitlabObject):
-    _url = '/namespaces'
-    canGet = 'from_list'
-    canUpdate = False
-    canDelete = False
-    canCreate = False
-    optionalListAttrs = ['search']
-
-
-class NamespaceManager(BaseManager):
-    obj_cls = Namespace
-
-
-class ProjectBoardList(GitlabObject):
-    _url = '/projects/%(project_id)s/boards/%(board_id)s/lists'
-    requiredUrlAttrs = ['project_id', 'board_id']
-    _constructorTypes = {'label': 'ProjectLabel'}
-    requiredCreateAttrs = ['label_id']
-    requiredUpdateAttrs = ['position']
-
-
-class ProjectBoardListManager(BaseManager):
-    obj_cls = ProjectBoardList
-
-
-class ProjectBoard(GitlabObject):
-    _url = '/projects/%(project_id)s/boards'
-    requiredUrlAttrs = ['project_id']
-    _constructorTypes = {'labels': 'ProjectBoardList'}
-    canGet = 'from_list'
-    canUpdate = False
-    canCreate = False
-    canDelete = False
-    managers = (
-        ('lists', ProjectBoardListManager,
-            [('project_id', 'project_id'), ('board_id', 'id')]),
-    )
-
-
-class ProjectBoardManager(BaseManager):
-    obj_cls = ProjectBoard
-
-
-class ProjectBranch(GitlabObject):
-    _url = '/projects/%(project_id)s/repository/branches'
-    _constructorTypes = {'author': 'User', "committer": "User"}
-
-    idAttr = 'name'
-    canUpdate = False
-    requiredUrlAttrs = ['project_id']
-    requiredCreateAttrs = ['branch_name', 'ref']
-
-    def protect(self, protect=True, **kwargs):
-        """Protects the project."""
-        url = self._url % {'project_id': self.project_id}
-        action = 'protect' if protect else 'unprotect'
-        url = "%s/%s/%s" % (url, self.name, action)
-        r = self.gitlab._raw_put(url, data=None, content_type=None, **kwargs)
-        raise_error_from_response(r, GitlabProtectError)
-
-        if protect:
-            self.protected = protect
-        else:
-            del self.protected
-
-    def unprotect(self, **kwargs):
-        """Unprotects the project."""
-        self.protect(False, **kwargs)
-
-
-class ProjectBranchManager(BaseManager):
-    obj_cls = ProjectBranch
-
-
-class ProjectBuild(GitlabObject):
-    _url = '/projects/%(project_id)s/builds'
-    _constructorTypes = {'user': 'User',
-                         'commit': 'ProjectCommit',
-                         'runner': 'Runner'}
-    requiredUrlAttrs = ['project_id']
-    canDelete = False
-    canUpdate = False
-    canCreate = False
-
-    def cancel(self, **kwargs):
-        """Cancel the build."""
-        url = '/projects/%s/builds/%s/cancel' % (self.project_id, self.id)
-        r = self.gitlab._raw_post(url)
-        raise_error_from_response(r, GitlabBuildCancelError, 201)
-
-    def retry(self, **kwargs):
-        """Retry the build."""
-        url = '/projects/%s/builds/%s/retry' % (self.project_id, self.id)
-        r = self.gitlab._raw_post(url)
-        raise_error_from_response(r, GitlabBuildRetryError, 201)
-
-    def play(self, **kwargs):
-        """Trigger a build explicitly."""
-        url = '/projects/%s/builds/%s/play' % (self.project_id, self.id)
-        r = self.gitlab._raw_post(url)
-        raise_error_from_response(r, GitlabBuildPlayError)
-
-    def erase(self, **kwargs):
-        """Erase the build (remove build artifacts and trace)."""
-        url = '/projects/%s/builds/%s/erase' % (self.project_id, self.id)
-        r = self.gitlab._raw_post(url)
-        raise_error_from_response(r, GitlabBuildEraseError, 201)
-
-    def keep_artifacts(self, **kwargs):
-        """Prevent artifacts from being delete when expiration is set.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabCreateError: If the request failed.
-        """
-        url = ('/projects/%s/builds/%s/artifacts/keep' %
-               (self.project_id, self.id))
-        r = self.gitlab._raw_post(url)
-        raise_error_from_response(r, GitlabGetError, 200)
-
-    def artifacts(self, streamed=False, action=None, chunk_size=1024,
-                  **kwargs):
-        """Get the build artifacts.
-
-        Args:
-            streamed (bool): If True the data will be processed by chunks of
-                `chunk_size` and each chunk is passed to `action` for
-                treatment.
-            action (callable): Callable responsible of dealing with chunk of
-                data.
-            chunk_size (int): Size of each chunk.
-
-        Returns:
-            str: The artifacts if `streamed` is False, None otherwise.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabGetError: If the artifacts are not available.
-        """
-        url = '/projects/%s/builds/%s/artifacts' % (self.project_id, self.id)
-        r = self.gitlab._raw_get(url, streamed=streamed, **kwargs)
-        raise_error_from_response(r, GitlabGetError, 200)
-        return utils.response_content(r, streamed, action, chunk_size)
-
-    def trace(self, streamed=False, action=None, chunk_size=1024, **kwargs):
-        """Get the build trace.
-
-        Args:
-            streamed (bool): If True the data will be processed by chunks of
-                `chunk_size` and each chunk is passed to `action` for
-                treatment.
-            action (callable): Callable responsible of dealing with chunk of
-                data.
-            chunk_size (int): Size of each chunk.
-
-        Returns:
-            str: The trace.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabGetError: If the trace is not available.
-        """
-        url = '/projects/%s/builds/%s/trace' % (self.project_id, self.id)
-        r = self.gitlab._raw_get(url, streamed=streamed, **kwargs)
-        raise_error_from_response(r, GitlabGetError, 200)
-        return utils.response_content(r, streamed, action, chunk_size)
-
-
-class ProjectBuildManager(BaseManager):
-    obj_cls = ProjectBuild
-
-
-class ProjectCommitStatus(GitlabObject):
-    _url = '/projects/%(project_id)s/repository/commits/%(commit_id)s/statuses'
-    _create_url = '/projects/%(project_id)s/statuses/%(commit_id)s'
-    canUpdate = False
-    canDelete = False
-    requiredUrlAttrs = ['project_id', 'commit_id']
-    optionalGetAttrs = ['ref_name', 'stage', 'name', 'all']
-    requiredCreateAttrs = ['state']
-    optionalCreateAttrs = ['description', 'name', 'context', 'ref',
-                           'target_url']
-
-
-class ProjectCommitStatusManager(BaseManager):
-    obj_cls = ProjectCommitStatus
-
-
-class ProjectCommitComment(GitlabObject):
-    _url = '/projects/%(project_id)s/repository/commits/%(commit_id)s/comments'
-    canUpdate = False
-    canGet = False
-    canDelete = False
-    requiredUrlAttrs = ['project_id', 'commit_id']
-    requiredCreateAttrs = ['note']
-    optionalCreateAttrs = ['path', 'line', 'line_type']
-
-
-class ProjectCommitCommentManager(BaseManager):
-    obj_cls = ProjectCommitComment
-
-
-class ProjectCommit(GitlabObject):
-    _url = '/projects/%(project_id)s/repository/commits'
-    canDelete = False
-    canUpdate = False
-    canCreate = False
-    requiredUrlAttrs = ['project_id']
-    shortPrintAttr = 'title'
-    managers = (
-        ('comments', ProjectCommitCommentManager,
-            [('project_id', 'project_id'), ('commit_id', 'id')]),
-        ('statuses', ProjectCommitStatusManager,
-            [('project_id', 'project_id'), ('commit_id', 'id')]),
-    )
-
-    def diff(self, **kwargs):
-        """Generate the commit diff."""
-        url = ('/projects/%(project_id)s/repository/commits/%(commit_id)s/diff'
-               % {'project_id': self.project_id, 'commit_id': self.id})
-        r = self.gitlab._raw_get(url, **kwargs)
-        raise_error_from_response(r, GitlabGetError)
-
-        return r.json()
-
-    def blob(self, filepath, streamed=False, action=None, chunk_size=1024,
-             **kwargs):
-        """Generate the content of a file for this commit.
-
-        Args:
-            filepath (str): Path of the file to request.
-            streamed (bool): If True the data will be processed by chunks of
-                `chunk_size` and each chunk is passed to `action` for
-                treatment.
-            action (callable): Callable responsible of dealing with chunk of
-                data.
-            chunk_size (int): Size of each chunk.
-
-        Returns:
-            str: The content of the file
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabGetError: If the server fails to perform the request.
-        """
-        url = ('/projects/%(project_id)s/repository/blobs/%(commit_id)s' %
-               {'project_id': self.project_id, 'commit_id': self.id})
-        url += '?filepath=%s' % filepath
-        r = self.gitlab._raw_get(url, streamed=streamed, **kwargs)
-        raise_error_from_response(r, GitlabGetError)
-        return utils.response_content(r, streamed, action, chunk_size)
-
-    def builds(self, **kwargs):
-        """List the build for this commit.
-
-        Returns:
-            list(ProjectBuild): A list of builds.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabListError: If the server fails to perform the request.
-        """
-        url = '/projects/%s/repository/commits/%s/builds' % (self.project_id,
-                                                             self.id)
-        return self.gitlab._raw_list(url, ProjectBuild,
-                                     {'project_id': self.project_id},
-                                     **kwargs)
-
-
-class ProjectCommitManager(BaseManager):
-    obj_cls = ProjectCommit
-
-
-class ProjectEnvironment(GitlabObject):
-    _url = '/projects/%(project_id)s/environments'
-    canGet = 'from_list'
-    requiredUrlAttrs = ['project_id']
-    requiredCreateAttrs = ['name']
-    optionalCreateAttrs = ['external_url']
-    optionalUpdateAttrs = ['name', 'external_url']
-
-
-class ProjectEnvironmentManager(BaseManager):
-    obj_cls = ProjectEnvironment
-
-
-class ProjectKey(GitlabObject):
-    _url = '/projects/%(project_id)s/keys'
-    canUpdate = False
-    requiredUrlAttrs = ['project_id']
-    requiredCreateAttrs = ['title', 'key']
-
-    def enable(self):
-        """Enable a deploy key for a project."""
-        url = '/projects/%s/deploy_keys/%s/enable' % (self.project_id, self.id)
-        r = self.gitlab._raw_post(url)
-        raise_error_from_response(r, GitlabProjectDeployKeyError, 201)
-
-    def disable(self):
-        """Disable a deploy key for a project."""
-        url = '/projects/%s/deploy_keys/%s/disable' % (self.project_id,
-                                                       self.id)
-        r = self.gitlab._raw_delete(url)
-        raise_error_from_response(r, GitlabProjectDeployKeyError, 200)
-
-
-class ProjectKeyManager(BaseManager):
-    obj_cls = ProjectKey
-
-
-class ProjectEvent(GitlabObject):
-    _url = '/projects/%(project_id)s/events'
-    canGet = 'from_list'
-    canDelete = False
-    canUpdate = False
-    canCreate = False
-    requiredUrlAttrs = ['project_id']
-    shortPrintAttr = 'target_title'
-
-
-class ProjectEventManager(BaseManager):
-    obj_cls = ProjectEvent
-
-
-class ProjectFork(GitlabObject):
-    _url = '/projects/fork/%(project_id)s'
-    canUpdate = False
-    canDelete = False
-    canList = False
-    canGet = False
-    requiredUrlAttrs = ['project_id']
-    optionalCreateAttrs = ['namespace']
-
-
-class ProjectForkManager(BaseManager):
-    obj_cls = ProjectFork
-
-
-class ProjectHook(GitlabObject):
-    _url = '/projects/%(project_id)s/hooks'
-    requiredUrlAttrs = ['project_id']
-    requiredCreateAttrs = ['url']
-    optionalCreateAttrs = ['push_events', 'issues_events', 'note_events',
-                           'merge_requests_events', 'tag_push_events',
-                           'build_events', 'enable_ssl_verification', 'token']
-    shortPrintAttr = 'url'
-
-
-class ProjectHookManager(BaseManager):
-    obj_cls = ProjectHook
-
-
-class ProjectIssueNote(GitlabObject):
-    _url = '/projects/%(project_id)s/issues/%(issue_id)s/notes'
-    _constructorTypes = {'author': 'User'}
-    canDelete = False
-    requiredUrlAttrs = ['project_id', 'issue_id']
-    requiredCreateAttrs = ['body']
-    optionalCreateAttrs = ['created_at']
-
-
-class ProjectIssueNoteManager(BaseManager):
-    obj_cls = ProjectIssueNote
-
-
-class ProjectIssue(GitlabObject):
-    _url = '/projects/%(project_id)s/issues/'
-    _constructorTypes = {'author': 'User', 'assignee': 'User',
-                         'milestone': 'ProjectMilestone'}
-    optionalListAttrs = ['state', 'labels', 'milestone', 'iid', 'order_by',
-                         'sort']
-    requiredUrlAttrs = ['project_id']
-    requiredCreateAttrs = ['title']
-    optionalCreateAttrs = ['description', 'assignee_id', 'milestone_id',
-                           'labels', 'created_at']
-    optionalUpdateAttrs = ['title', 'description', 'assignee_id',
-                           'milestone_id', 'labels', 'created_at',
-                           'updated_at', 'state_event']
-    shortPrintAttr = 'title'
-    managers = (
-        ('notes', ProjectIssueNoteManager,
-            [('project_id', 'project_id'), ('issue_id', 'id')]),
-    )
-
-    def _data_for_gitlab(self, extra_parameters={}, update=False,
-                         as_json=True):
-        # Gitlab-api returns labels in a json list and takes them in a
-        # comma separated list.
-        if hasattr(self, "labels"):
-            if (self.labels is not None and
-               not isinstance(self.labels, six.string_types)):
-                labels = ", ".join(self.labels)
-                extra_parameters['labels'] = labels
-
-        return super(ProjectIssue, self)._data_for_gitlab(extra_parameters,
-                                                          update)
-
-    def subscribe(self, **kwargs):
-        """Subscribe to an issue.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabSubscribeError: If the subscription cannot be done
-        """
-        url = ('/projects/%(project_id)s/issues/%(issue_id)s/subscription' %
-               {'project_id': self.project_id, 'issue_id': self.id})
-
-        r = self.gitlab._raw_post(url, **kwargs)
-        raise_error_from_response(r, GitlabSubscribeError)
-        self._set_from_dict(r.json())
-
-    def unsubscribe(self, **kwargs):
-        """Unsubscribe an issue.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabUnsubscribeError: If the unsubscription cannot be done
-        """
-        url = ('/projects/%(project_id)s/issues/%(issue_id)s/subscription' %
-               {'project_id': self.project_id, 'issue_id': self.id})
-
-        r = self.gitlab._raw_delete(url, **kwargs)
-        raise_error_from_response(r, GitlabUnsubscribeError)
-        self._set_from_dict(r.json())
-
-    def move(self, to_project_id, **kwargs):
-        """Move the issue to another project.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-        """
-        url = ('/projects/%(project_id)s/issues/%(issue_id)s/move' %
-               {'project_id': self.project_id, 'issue_id': self.id})
-
-        data = {'to_project_id': to_project_id}
-        data.update(**kwargs)
-        r = self.gitlab._raw_post(url, data=data)
-        raise_error_from_response(r, GitlabUpdateError, 201)
-        self._set_from_dict(r.json())
-
-    def todo(self, **kwargs):
-        """Create a todo for the issue.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-        """
-        url = ('/projects/%(project_id)s/issues/%(issue_id)s/todo' %
-               {'project_id': self.project_id, 'issue_id': self.id})
-        r = self.gitlab._raw_post(url, **kwargs)
-        raise_error_from_response(r, GitlabTodoError, [201, 304])
-
-
-class ProjectIssueManager(BaseManager):
-    obj_cls = ProjectIssue
-
-
-class ProjectMember(GitlabObject):
-    _url = '/projects/%(project_id)s/members'
-    requiredUrlAttrs = ['project_id']
-    requiredCreateAttrs = ['access_level', 'user_id']
-    requiredUpdateAttrs = ['access_level']
-    shortPrintAttr = 'username'
-
-
-class ProjectMemberManager(BaseManager):
-    obj_cls = ProjectMember
-
-
-class ProjectNote(GitlabObject):
-    _url = '/projects/%(project_id)s/notes'
-    _constructorTypes = {'author': 'User'}
-    canUpdate = False
-    canDelete = False
-    requiredUrlAttrs = ['project_id']
-    requiredCreateAttrs = ['body']
-
-
-class ProjectNoteManager(BaseManager):
-    obj_cls = ProjectNote
-
-
-class ProjectNotificationSettings(NotificationSettings):
-    _url = '/projects/%(project_id)s/notification_settings'
-    requiredUrlAttrs = ['project_id']
-
-
-class ProjectNotificationSettingsManager(BaseManager):
-    obj_cls = ProjectNotificationSettings
-
-
-class ProjectTagRelease(GitlabObject):
-    _url = '/projects/%(project_id)s/repository/tags/%(tag_name)/release'
-    canDelete = False
-    canList = False
-    requiredUrlAttrs = ['project_id', 'tag_name']
-    requiredCreateAttrs = ['description']
-    shortPrintAttr = 'description'
-
-
-class ProjectTag(GitlabObject):
-    _url = '/projects/%(project_id)s/repository/tags'
-    _constructorTypes = {'release': 'ProjectTagRelease',
-                         'commit': 'ProjectCommit'}
-    idAttr = 'name'
-    canGet = 'from_list'
-    canUpdate = False
-    requiredUrlAttrs = ['project_id']
-    requiredCreateAttrs = ['tag_name', 'ref']
-    optionalCreateAttrs = ['message']
-    shortPrintAttr = 'name'
-
-    def set_release_description(self, description):
-        """Set the release notes on the tag.
-
-        If the release doesn't exist yet, it will be created. If it already
-        exists, its description will be updated.
-
-        Args:
-            description (str): Description of the release.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabCreateError: If the server fails to create the release.
-            GitlabUpdateError: If the server fails to update the release.
-        """
-        url = '/projects/%s/repository/tags/%s/release' % (self.project_id,
-                                                           self.name)
-        if self.release is None:
-            r = self.gitlab._raw_post(url, data={'description': description})
-            raise_error_from_response(r, GitlabCreateError, 201)
-        else:
-            r = self.gitlab._raw_put(url, data={'description': description})
-            raise_error_from_response(r, GitlabUpdateError, 200)
-        self.release = ProjectTagRelease(self, r.json())
-
-
-class ProjectTagManager(BaseManager):
-    obj_cls = ProjectTag
-
-
-class ProjectMergeRequestDiff(GitlabObject):
-    _url = ('/projects/%(project_id)s/merge_requests/'
-            '%(merge_request_id)s/versions')
-    canCreate = False
-    canUpdate = False
-    canDelete = False
-    requiredUrlAttrs = ['project_id', 'merge_request_id']
-
-
-class ProjectMergeRequestDiffManager(BaseManager):
-    obj_cls = ProjectMergeRequestDiff
-
-
-class ProjectMergeRequestNote(GitlabObject):
-    _url = '/projects/%(project_id)s/merge_requests/%(merge_request_id)s/notes'
-    _constructorTypes = {'author': 'User'}
-    canDelete = False
-    requiredUrlAttrs = ['project_id', 'merge_request_id']
-    requiredCreateAttrs = ['body']
-
-
-class ProjectMergeRequestNoteManager(BaseManager):
-    obj_cls = ProjectMergeRequestNote
-
-
-class ProjectMergeRequest(GitlabObject):
-    _url = '/projects/%(project_id)s/merge_requests'
-    _constructorTypes = {'author': 'User', 'assignee': 'User'}
-    requiredUrlAttrs = ['project_id']
-    requiredCreateAttrs = ['source_branch', 'target_branch', 'title']
-    optionalCreateAttrs = ['assignee_id', 'description', 'target_project_id',
-                           'labels', 'milestone_id']
-    optionalUpdateAttrs = ['target_branch', 'assignee_id', 'title',
-                           'description', 'state_event', 'labels',
-                           'milestone_id']
-    optionalListAttrs = ['iid', 'state', 'order_by', 'sort']
-
-    managers = (
-        ('notes', ProjectMergeRequestNoteManager,
-            [('project_id', 'project_id'), ('merge_request_id', 'id')]),
-        ('diffs', ProjectMergeRequestDiffManager,
-            [('project_id', 'project_id'), ('merge_request_id', 'id')]),
-    )
-
-    def _data_for_gitlab(self, extra_parameters={}, update=False,
-                         as_json=True):
-        data = (super(ProjectMergeRequest, self)
-                ._data_for_gitlab(extra_parameters, update=update,
-                                  as_json=False))
-        if update:
-            # Drop source_branch attribute as it is not accepted by the gitlab
-            # server (Issue #76)
-            data.pop('source_branch', None)
-        return json.dumps(data)
-
-    def subscribe(self, **kwargs):
-        """Subscribe to a MR.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabSubscribeError: If the subscription cannot be done
-        """
-        url = ('/projects/%(project_id)s/merge_requests/%(mr_id)s/'
-               'subscription' %
-               {'project_id': self.project_id, 'mr_id': self.id})
-
-        r = self.gitlab._raw_post(url, **kwargs)
-        raise_error_from_response(r, GitlabSubscribeError, [201, 304])
-        if r.status_code == 201:
-            self._set_from_dict(r.json())
-
-    def unsubscribe(self, **kwargs):
-        """Unsubscribe a MR.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabUnsubscribeError: If the unsubscription cannot be done
-        """
-        url = ('/projects/%(project_id)s/merge_requests/%(mr_id)s/'
-               'subscription' %
-               {'project_id': self.project_id, 'mr_id': self.id})
-
-        r = self.gitlab._raw_delete(url, **kwargs)
-        raise_error_from_response(r, GitlabUnsubscribeError, [200, 304])
-        if r.status_code == 200:
-            self._set_from_dict(r.json())
-
-    def cancel_merge_when_build_succeeds(self, **kwargs):
-        """Cancel merge when build succeeds."""
-
-        u = ('/projects/%s/merge_requests/%s/cancel_merge_when_build_succeeds'
-             % (self.project_id, self.id))
-        r = self.gitlab._raw_put(u, **kwargs)
-        errors = {401: GitlabMRForbiddenError,
-                  405: GitlabMRClosedError,
-                  406: GitlabMROnBuildSuccessError}
-        raise_error_from_response(r, errors)
-        return ProjectMergeRequest(self, r.json())
-
-    def closes_issues(self, **kwargs):
-        """List issues closed by the MR.
-
-        Returns:
-            list (ProjectIssue): List of closed issues
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabGetError: If the server fails to perform the request.
-        """
-        url = ('/projects/%s/merge_requests/%s/closes_issues' %
-               (self.project_id, self.id))
-        return self.gitlab._raw_list(url, ProjectIssue,
-                                     {'project_id': self.project_id},
-                                     **kwargs)
-
-    def commits(self, **kwargs):
-        """List the merge request commits.
-
-        Returns:
-            list (ProjectCommit): List of commits
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabListError: If the server fails to perform the request.
-        """
-        url = ('/projects/%s/merge_requests/%s/commits' %
-               (self.project_id, self.id))
-        return self.gitlab._raw_list(url, ProjectCommit,
-                                     {'project_id': self.project_id},
-                                     **kwargs)
-
-    def changes(self, **kwargs):
-        """List the merge request changes.
-
-        Returns:
-            list (dict): List of changes
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabListError: If the server fails to perform the request.
-        """
-        url = ('/projects/%s/merge_requests/%s/changes' %
-               (self.project_id, self.id))
-        r = self.gitlab._raw_get(url, **kwargs)
-        raise_error_from_response(r, GitlabListError)
-        return r.json()
-
-    def merge(self, merge_commit_message=None,
-              should_remove_source_branch=False,
-              merged_when_build_succeeds=False,
-              **kwargs):
-        """Accept the merge request.
-
-        Args:
-            merge_commit_message (bool): Commit message
-            should_remove_source_branch (bool): If True, removes the source
-                                                branch
-            merged_when_build_succeeds (bool): Wait for the build to succeed,
-                                               then merge
-
-        Returns:
-            ProjectMergeRequest: The updated MR
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabMRForbiddenError: If the user doesn't have permission to
-                                    close thr MR
-            GitlabMRClosedError: If the MR is already closed
-        """
-        url = '/projects/%s/merge_requests/%s/merge' % (self.project_id,
-                                                        self.id)
-        data = {}
-        if merge_commit_message:
-            data['merge_commit_message'] = merge_commit_message
-        if should_remove_source_branch:
-            data['should_remove_source_branch'] = True
-        if merged_when_build_succeeds:
-            data['merged_when_build_succeeds'] = True
-
-        r = self.gitlab._raw_put(url, data=data, **kwargs)
-        errors = {401: GitlabMRForbiddenError,
-                  405: GitlabMRClosedError}
-        raise_error_from_response(r, errors)
-        self._set_from_dict(r.json())
-
-    def todo(self, **kwargs):
-        """Create a todo for the merge request.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-        """
-        url = ('/projects/%(project_id)s/merge_requests/%(mr_id)s/todo' %
-               {'project_id': self.project_id, 'mr_id': self.id})
-        r = self.gitlab._raw_post(url, **kwargs)
-        raise_error_from_response(r, GitlabTodoError, [201, 304])
-
-
-class ProjectMergeRequestManager(BaseManager):
-    obj_cls = ProjectMergeRequest
-
-
-class ProjectMilestone(GitlabObject):
-    _url = '/projects/%(project_id)s/milestones'
-    canDelete = False
-    requiredUrlAttrs = ['project_id']
-    optionalListAttrs = ['iid', 'state']
-    requiredCreateAttrs = ['title']
-    optionalCreateAttrs = ['description', 'due_date', 'state_event']
-    optionalUpdateAttrs = requiredCreateAttrs + optionalCreateAttrs
-    shortPrintAttr = 'title'
-
-    def issues(self, **kwargs):
-        url = "/projects/%s/milestones/%s/issues" % (self.project_id, self.id)
-        return self.gitlab._raw_list(url, ProjectIssue,
-                                     {'project_id': self.project_id},
-                                     **kwargs)
-
-
-class ProjectMilestoneManager(BaseManager):
-    obj_cls = ProjectMilestone
-
-
-class ProjectLabel(GitlabObject):
-    _url = '/projects/%(project_id)s/labels'
-    _id_in_delete_url = False
-    _id_in_update_url = False
-    canGet = 'from_list'
-    requiredUrlAttrs = ['project_id']
-    idAttr = 'name'
-    requiredDeleteAttrs = ['name']
-    requiredCreateAttrs = ['name', 'color']
-    optionalCreateAttrs = ['description']
-    requiredUpdateAttrs = ['name']
-    optionalUpdateAttrs = ['new_name', 'color', 'description']
-
-    def subscribe(self, **kwargs):
-        """Subscribe to a label.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabSubscribeError: If the subscription cannot be done
-        """
-        url = ('/projects/%(project_id)s/labels/%(label_id)s/subscription' %
-               {'project_id': self.project_id, 'label_id': self.name})
-
-        r = self.gitlab._raw_post(url, **kwargs)
-        raise_error_from_response(r, GitlabSubscribeError, [201, 304])
-        self._set_from_dict(r.json())
-
-    def unsubscribe(self, **kwargs):
-        """Unsubscribe a label.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabUnsubscribeError: If the unsubscription cannot be done
-        """
-        url = ('/projects/%(project_id)s/labels/%(label_id)s/subscription' %
-               {'project_id': self.project_id, 'label_id': self.name})
-
-        r = self.gitlab._raw_delete(url, **kwargs)
-        raise_error_from_response(r, GitlabUnsubscribeError, [200, 304])
-        self._set_from_dict(r.json())
-
-
-class ProjectLabelManager(BaseManager):
-    obj_cls = ProjectLabel
-
-
-class ProjectFile(GitlabObject):
-    _url = '/projects/%(project_id)s/repository/files'
-    canList = False
-    requiredUrlAttrs = ['project_id']
-    requiredGetAttrs = ['file_path', 'ref']
-    requiredCreateAttrs = ['file_path', 'branch_name', 'content',
-                           'commit_message']
-    optionalCreateAttrs = ['encoding']
-    requiredDeleteAttrs = ['branch_name', 'commit_message', 'file_path']
-    shortPrintAttr = 'file_path'
-    getRequiresId = False
-
-    def decode(self):
-        """Returns the decoded content of the file.
-
-        Returns:
-            (str): the decoded content.
-        """
-        return base64.b64decode(self.content)
-
-
-class ProjectFileManager(BaseManager):
-    obj_cls = ProjectFile
-
-
-class ProjectPipeline(GitlabObject):
-    _url = '/projects/%(project_id)s/pipelines'
-    canCreate = False
-    canUpdate = False
-    canDelete = False
-
-    def retry(self, **kwargs):
-        """Retries failed builds in a pipeline.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabPipelineRetryError: If the retry cannot be done.
-        """
-        url = ('/projects/%(project_id)s/pipelines/%(id)s/retry' %
-               {'project_id': self.project_id, 'id': self.id})
-        r = self.gitlab._raw_post(url, data=None, content_type=None, **kwargs)
-        raise_error_from_response(r, GitlabPipelineRetryError, 201)
-        self._set_from_dict(r.json())
-
-    def cancel(self, **kwargs):
-        """Cancel builds in a pipeline.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabPipelineCancelError: If the retry cannot be done.
-        """
-        url = ('/projects/%(project_id)s/pipelines/%(id)s/cancel' %
-               {'project_id': self.project_id, 'id': self.id})
-        r = self.gitlab._raw_post(url, data=None, content_type=None, **kwargs)
-        raise_error_from_response(r, GitlabPipelineRetryError, 200)
-        self._set_from_dict(r.json())
-
-
-class ProjectPipelineManager(BaseManager):
-    obj_cls = ProjectPipeline
-
-
-class ProjectSnippetNote(GitlabObject):
-    _url = '/projects/%(project_id)s/snippets/%(snippet_id)s/notes'
-    _constructorTypes = {'author': 'User'}
-    canUpdate = False
-    canDelete = False
-    requiredUrlAttrs = ['project_id', 'snippet_id']
-    requiredCreateAttrs = ['body']
-
-
-class ProjectSnippetNoteManager(BaseManager):
-    obj_cls = ProjectSnippetNote
-
-
-class ProjectSnippet(GitlabObject):
-    _url = '/projects/%(project_id)s/snippets'
-    _constructorTypes = {'author': 'User'}
-    requiredUrlAttrs = ['project_id']
-    requiredCreateAttrs = ['title', 'file_name', 'code']
-    optionalCreateAttrs = ['lifetime', 'visibility_level']
-    optionalUpdateAttrs = ['title', 'file_name', 'code', 'visibility_level']
-    shortPrintAttr = 'title'
-    managers = (
-        ('notes', ProjectSnippetNoteManager,
-            [('project_id', 'project_id'), ('snippet_id', 'id')]),
-    )
-
-    def content(self, streamed=False, action=None, chunk_size=1024, **kwargs):
-        """Return the raw content of a snippet.
-
-        Args:
-            streamed (bool): If True the data will be processed by chunks of
-                `chunk_size` and each chunk is passed to `action` for
-                treatment.
-            action (callable): Callable responsible of dealing with chunk of
-                data.
-            chunk_size (int): Size of each chunk.
-
-        Returns:
-            str: The snippet content
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabGetError: If the server fails to perform the request.
-        """
-        url = ("/projects/%(project_id)s/snippets/%(snippet_id)s/raw" %
-               {'project_id': self.project_id, 'snippet_id': self.id})
-        r = self.gitlab._raw_get(url, **kwargs)
-        raise_error_from_response(r, GitlabGetError)
-        return utils.response_content(r, streamed, action, chunk_size)
-
-
-class ProjectSnippetManager(BaseManager):
-    obj_cls = ProjectSnippet
-
-
-class ProjectTrigger(GitlabObject):
-    _url = '/projects/%(project_id)s/triggers'
-    canUpdate = False
-    idAttr = 'token'
-    requiredUrlAttrs = ['project_id']
-
-
-class ProjectTriggerManager(BaseManager):
-    obj_cls = ProjectTrigger
-
-
-class ProjectVariable(GitlabObject):
-    _url = '/projects/%(project_id)s/variables'
-    idAttr = 'key'
-    requiredUrlAttrs = ['project_id']
-    requiredCreateAttrs = ['key', 'value']
-
-
-class ProjectVariableManager(BaseManager):
-    obj_cls = ProjectVariable
-
-
-class ProjectService(GitlabObject):
-    _url = '/projects/%(project_id)s/services/%(service_name)s'
-    canList = False
-    canCreate = False
-    _id_in_update_url = False
-    _id_in_delete_url = False
-    requiredUrlAttrs = ['project_id', 'service_name']
-
-    _service_attrs = {
-        'asana': (('api_key', ), ('restrict_to_branch', )),
-        'assembla': (('token', ), ('subdomain', )),
-        'bamboo': (('bamboo_url', 'build_key', 'username', 'password'),
-                   tuple()),
-        'buildkite': (('token', 'project_url'), ('enable_ssl_verification', )),
-        'campfire': (('token', ), ('subdomain', 'room')),
-        'custom-issue-tracker': (('new_issue_url', 'issues_url',
-                                  'project_url'),
-                                 ('description', 'title')),
-        'drone-ci': (('token', 'drone_url'), ('enable_ssl_verification', )),
-        'emails-on-push': (('recipients', ), ('disable_diffs',
-                                              'send_from_committer_email')),
-        'external-wiki': (('external_wiki_url', ), tuple()),
-        'flowdock': (('token', ), tuple()),
-        'gemnasium': (('api_key', 'token', ), tuple()),
-        'hipchat': (('token', ), ('color', 'notify', 'room', 'api_version',
-                                  'server')),
-        'irker': (('recipients', ), ('default_irc_uri', 'server_port',
-                                     'server_host', 'colorize_messages')),
-        'jira': (tuple(), (
-                 # Required fields in GitLab >= 8.14
-                 'url', 'project_key',
-
-                 # Required fields in GitLab < 8.14
-                 'new_issue_url', 'project_url', 'issues_url', 'api_url',
-                 'description',
-
-                 # Optional fields
-                 'username', 'password', 'jira_issue_transition_id')),
-        'pivotaltracker': (('token', ), tuple()),
-        'pushover': (('api_key', 'user_key', 'priority'), ('device', 'sound')),
-        'redmine': (('new_issue_url', 'project_url', 'issues_url'),
-                    ('description', )),
-        'slack': (('webhook', ), ('username', 'channel')),
-        'teamcity': (('teamcity_url', 'build_type', 'username', 'password'),
-                     tuple())
-    }
-
-    def _data_for_gitlab(self, extra_parameters={}, update=False,
-                         as_json=True):
-        data = (super(ProjectService, self)
-                ._data_for_gitlab(extra_parameters, update=update,
-                                  as_json=False))
-        missing = []
-        # Mandatory args
-        for attr in self._service_attrs[self.service_name][0]:
-            if not hasattr(self, attr):
-                missing.append(attr)
-            else:
-                data[attr] = getattr(self, attr)
-
-        if missing:
-            raise GitlabUpdateError('Missing attribute(s): %s' %
-                                    ", ".join(missing))
-
-        # Optional args
-        for attr in self._service_attrs[self.service_name][1]:
-            if hasattr(self, attr):
-                data[attr] = getattr(self, attr)
-
-        return json.dumps(data)
-
-
-class ProjectServiceManager(BaseManager):
-    obj_cls = ProjectService
-
-    def available(self, **kwargs):
-        """List the services known by python-gitlab.
-
-        Returns:
-            list (str): The list of service code names.
-        """
-        return json.dumps(ProjectService._service_attrs.keys())
-
-
-class ProjectAccessRequest(GitlabObject):
-    _url = '/projects/%(project_id)s/access_requests'
-    canGet = 'from_list'
-    canUpdate = False
-
-    def approve(self, access_level=gitlab.DEVELOPER_ACCESS, **kwargs):
-        """Approve an access request.
-
-        Attrs:
-            access_level (int): The access level for the user.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabUpdateError: If the server fails to perform the request.
-        """
-
-        url = ('/projects/%(project_id)s/access_requests/%(id)s/approve' %
-               {'project_id': self.project_id, 'id': self.id})
-        data = {'access_level': access_level}
-        r = self.gitlab._raw_put(url, data=data, **kwargs)
-        raise_error_from_response(r, GitlabUpdateError, 201)
-        self._set_from_dict(r.json())
-
-
-class ProjectAccessRequestManager(BaseManager):
-    obj_cls = ProjectAccessRequest
-
-
-class ProjectDeployment(GitlabObject):
-    _url = '/projects/%(project_id)s/deployments'
-    canCreate = False
-    canUpdate = False
-    canDelete = False
-
-
-class ProjectDeploymentManager(BaseManager):
-    obj_cls = ProjectDeployment
-
-
-class Project(GitlabObject):
-    _url = '/projects'
-    _constructorTypes = {'owner': 'User', 'namespace': 'Group'}
-    requiredCreateAttrs = ['name']
-    optionalCreateAttrs = ['path', 'namespace_id', 'description',
-                           'issues_enabled', 'merge_requests_enabled',
-                           'builds_enabled', 'wiki_enabled',
-                           'snippets_enabled', 'container_registry_enabled',
-                           'shared_runners_enabled', 'public',
-                           'visibility_level', 'import_url', 'public_builds',
-                           'only_allow_merge_if_build_succeeds',
-                           'only_allow_merge_if_all_discussions_are_resolved',
-                           'lfs_enabled', 'request_access_enabled']
-    optionalUpdateAttrs = ['name', 'path', 'default_branch', 'description',
-                           'issues_enabled', 'merge_requests_enabled',
-                           'builds_enabled', 'wiki_enabled',
-                           'snippets_enabled', 'container_registry_enabled',
-                           'shared_runners_enabled', 'public',
-                           'visibility_level', 'import_url', 'public_builds',
-                           'only_allow_merge_if_build_succeeds',
-                           'only_allow_merge_if_all_discussions_are_resolved',
-                           'lfs_enabled', 'request_access_enabled']
-    shortPrintAttr = 'path'
-    managers = (
-        ('accessrequests', ProjectAccessRequestManager,
-         [('project_id', 'id')]),
-        ('boards', ProjectBoardManager, [('project_id', 'id')]),
-        ('board_lists', ProjectBoardListManager, [('project_id', 'id')]),
-        ('branches', ProjectBranchManager, [('project_id', 'id')]),
-        ('builds', ProjectBuildManager, [('project_id', 'id')]),
-        ('commits', ProjectCommitManager, [('project_id', 'id')]),
-        ('deployments', ProjectDeploymentManager, [('project_id', 'id')]),
-        ('environments', ProjectEnvironmentManager, [('project_id', 'id')]),
-        ('events', ProjectEventManager, [('project_id', 'id')]),
-        ('files', ProjectFileManager, [('project_id', 'id')]),
-        ('forks', ProjectForkManager, [('project_id', 'id')]),
-        ('hooks', ProjectHookManager, [('project_id', 'id')]),
-        ('keys', ProjectKeyManager, [('project_id', 'id')]),
-        ('issues', ProjectIssueManager, [('project_id', 'id')]),
-        ('labels', ProjectLabelManager, [('project_id', 'id')]),
-        ('members', ProjectMemberManager, [('project_id', 'id')]),
-        ('mergerequests', ProjectMergeRequestManager, [('project_id', 'id')]),
-        ('milestones', ProjectMilestoneManager, [('project_id', 'id')]),
-        ('notes', ProjectNoteManager, [('project_id', 'id')]),
-        ('notificationsettings', ProjectNotificationSettingsManager,
-         [('project_id', 'id')]),
-        ('pipelines', ProjectPipelineManager, [('project_id', 'id')]),
-        ('services', ProjectServiceManager, [('project_id', 'id')]),
-        ('snippets', ProjectSnippetManager, [('project_id', 'id')]),
-        ('tags', ProjectTagManager, [('project_id', 'id')]),
-        ('triggers', ProjectTriggerManager, [('project_id', 'id')]),
-        ('variables', ProjectVariableManager, [('project_id', 'id')]),
-    )
-
-    VISIBILITY_PRIVATE = gitlab.VISIBILITY_PRIVATE
-    VISIBILITY_INTERNAL = gitlab.VISIBILITY_INTERNAL
-    VISIBILITY_PUBLIC = gitlab.VISIBILITY_PUBLIC
-
-    def repository_tree(self, path='', ref_name='', **kwargs):
-        """Return a list of files in the repository.
-
-        Args:
-            path (str): Path of the top folder (/ by default)
-            ref_name (str): Reference to a commit or branch
-
-        Returns:
-            str: The json representation of the tree.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabGetError: If the server fails to perform the request.
-        """
-        url = "/projects/%s/repository/tree" % (self.id)
-        params = []
-        if path:
-            params.append("path=%s" % path)
-        if ref_name:
-            params.append("ref_name=%s" % ref_name)
-        if params:
-            url += '?' + "&".join(params)
-        r = self.gitlab._raw_get(url, **kwargs)
-        raise_error_from_response(r, GitlabGetError)
-        return r.json()
-
-    def repository_blob(self, sha, filepath, streamed=False, action=None,
-                        chunk_size=1024, **kwargs):
-        """Return the content of a file for a commit.
-
-        Args:
-            sha (str): ID of the commit
-            filepath (str): Path of the file to return
-            streamed (bool): If True the data will be processed by chunks of
-                `chunk_size` and each chunk is passed to `action` for
-                treatment.
-            action (callable): Callable responsible of dealing with chunk of
-                data.
-            chunk_size (int): Size of each chunk.
-
-        Returns:
-            str: The file content
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabGetError: If the server fails to perform the request.
-        """
-        url = "/projects/%s/repository/blobs/%s" % (self.id, sha)
-        url += '?filepath=%s' % (filepath)
-        r = self.gitlab._raw_get(url, streamed=streamed, **kwargs)
-        raise_error_from_response(r, GitlabGetError)
-        return utils.response_content(r, streamed, action, chunk_size)
-
-    def repository_raw_blob(self, sha, streamed=False, action=None,
-                            chunk_size=1024, **kwargs):
-        """Returns the raw file contents for a blob by blob SHA.
-
-        Args:
-            sha(str): ID of the blob
-            streamed (bool): If True the data will be processed by chunks of
-                `chunk_size` and each chunk is passed to `action` for
-                treatment.
-            action (callable): Callable responsible of dealing with chunk of
-                data.
-            chunk_size (int): Size of each chunk.
-
-        Returns:
-            str: The blob content
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabGetError: If the server fails to perform the request.
-        """
-        url = "/projects/%s/repository/raw_blobs/%s" % (self.id, sha)
-        r = self.gitlab._raw_get(url, streamed=streamed, **kwargs)
-        raise_error_from_response(r, GitlabGetError)
-        return utils.response_content(r, streamed, action, chunk_size)
-
-    def repository_compare(self, from_, to, **kwargs):
-        """Returns a diff between two branches/commits.
-
-        Args:
-            from_(str): orig branch/SHA
-            to(str): dest branch/SHA
-
-        Returns:
-            str: The diff
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabGetError: If the server fails to perform the request.
-        """
-        url = "/projects/%s/repository/compare" % self.id
-        url = "%s?from=%s&to=%s" % (url, from_, to)
-        r = self.gitlab._raw_get(url, **kwargs)
-        raise_error_from_response(r, GitlabGetError)
-        return r.json()
-
-    def repository_contributors(self):
-        """Returns a list of contributors for the project.
-
-        Returns:
-            list: The contibutors
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabGetError: If the server fails to perform the request.
-        """
-        url = "/projects/%s/repository/contributors" % self.id
-        r = self.gitlab._raw_get(url)
-        raise_error_from_response(r, GitlabListError)
-        return r.json()
-
-    def repository_archive(self, sha=None, streamed=False, action=None,
-                           chunk_size=1024, **kwargs):
-        """Return a tarball of the repository.
-
-        Args:
-            sha (str): ID of the commit (default branch by default).
-            streamed (bool): If True the data will be processed by chunks of
-                `chunk_size` and each chunk is passed to `action` for
-                treatment.
-            action (callable): Callable responsible of dealing with chunk of
-                data.
-            chunk_size (int): Size of each chunk.
-
-        Returns:
-            str: The binary data of the archive.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabGetError: If the server fails to perform the request.
-        """
-        url = '/projects/%s/repository/archive' % self.id
-        if sha:
-            url += '?sha=%s' % sha
-        r = self.gitlab._raw_get(url, streamed=streamed, **kwargs)
-        raise_error_from_response(r, GitlabGetError)
-        return utils.response_content(r, streamed, action, chunk_size)
-
-    def create_fork_relation(self, forked_from_id):
-        """Create a forked from/to relation between existing projects.
-
-        Args:
-            forked_from_id (int): The ID of the project that was forked from
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabCreateError: If the server fails to perform the request.
-        """
-        url = "/projects/%s/fork/%s" % (self.id, forked_from_id)
-        r = self.gitlab._raw_post(url)
-        raise_error_from_response(r, GitlabCreateError, 201)
-
-    def delete_fork_relation(self):
-        """Delete a forked relation between existing projects.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabDeleteError: If the server fails to perform the request.
-        """
-        url = "/projects/%s/fork" % self.id
-        r = self.gitlab._raw_delete(url)
-        raise_error_from_response(r, GitlabDeleteError)
-
-    def star(self, **kwargs):
-        """Star a project.
-
-        Returns:
-            Project: the updated Project
-
-        Raises:
-            GitlabCreateError: If the action cannot be done
-            GitlabConnectionError: If the server cannot be reached.
-        """
-        url = "/projects/%s/star" % self.id
-        r = self.gitlab._raw_post(url, **kwargs)
-        raise_error_from_response(r, GitlabCreateError, [201, 304])
-        return Project(self.gitlab, r.json()) if r.status_code == 201 else self
-
-    def unstar(self, **kwargs):
-        """Unstar a project.
-
-        Returns:
-            Project: the updated Project
-
-        Raises:
-            GitlabDeleteError: If the action cannot be done
-            GitlabConnectionError: If the server cannot be reached.
-        """
-        url = "/projects/%s/star" % self.id
-        r = self.gitlab._raw_delete(url, **kwargs)
-        raise_error_from_response(r, GitlabDeleteError, [200, 304])
-        return Project(self.gitlab, r.json()) if r.status_code == 200 else self
-
-    def archive(self, **kwargs):
-        """Archive a project.
-
-        Returns:
-            Project: the updated Project
-
-        Raises:
-            GitlabCreateError: If the action cannot be done
-            GitlabConnectionError: If the server cannot be reached.
-        """
-        url = "/projects/%s/archive" % self.id
-        r = self.gitlab._raw_post(url, **kwargs)
-        raise_error_from_response(r, GitlabCreateError, 201)
-        return Project(self.gitlab, r.json()) if r.status_code == 201 else self
-
-    def archive_(self, **kwargs):
-        warnings.warn("`archive_()` is deprecated, use `archive()` instead",
-                      DeprecationWarning)
-        return self.archive(**kwargs)
-
-    def unarchive(self, **kwargs):
-        """Unarchive a project.
-
-        Returns:
-            Project: the updated Project
-
-        Raises:
-            GitlabDeleteError: If the action cannot be done
-            GitlabConnectionError: If the server cannot be reached.
-        """
-        url = "/projects/%s/unarchive" % self.id
-        r = self.gitlab._raw_delete(url, **kwargs)
-        raise_error_from_response(r, GitlabCreateError, 201)
-        return Project(self.gitlab, r.json()) if r.status_code == 201 else self
-
-    def unarchive_(self, **kwargs):
-        warnings.warn("`unarchive_()` is deprecated, "
-                      "use `unarchive()` instead",
-                      DeprecationWarning)
-        return self.unarchive(**kwargs)
-
-    def share(self, group_id, group_access, **kwargs):
-        """Share the project with a group.
-
-        Args:
-            group_id (int): ID of the group.
-            group_access (int): Access level for the group.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabCreateError: If the server fails to perform the request.
-        """
-        url = "/projects/%s/share" % self.id
-        data = {'group_id': group_id, 'group_access': group_access}
-        r = self.gitlab._raw_post(url, data=data, **kwargs)
-        raise_error_from_response(r, GitlabCreateError, 201)
-
-    def trigger_build(self, ref, token, variables={}, **kwargs):
-        """Trigger a CI build.
-
-        See https://gitlab.com/help/ci/triggers/README.md#trigger-a-build
-
-        Args:
-            ref (str): Commit to build; can be a commit SHA, a branch name, ...
-            token (str): The trigger token
-            variables (dict): Variables passed to the build script
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabCreateError: If the server fails to perform the request.
-        """
-        url = "/projects/%s/trigger/builds" % self.id
-        form = {r'variables[%s]' % k: v for k, v in six.iteritems(variables)}
-        data = {'ref': ref, 'token': token}
-        data.update(form)
-        r = self.gitlab._raw_post(url, data=data, **kwargs)
-        raise_error_from_response(r, GitlabCreateError, 201)
-
-
-class Runner(GitlabObject):
-    _url = '/runners'
-    canCreate = False
-    optionalUpdateAttrs = ['description', 'active', 'tag_list']
-
-
-class RunnerManager(BaseManager):
-    obj_cls = Runner
-
-    def all(self, scope=None, **kwargs):
-        """List all the runners.
-
-        Args:
-            scope (str): The scope of runners to show, one of: specific,
-                shared, active, paused, online
-
-        Returns:
-            list(Runner): a list of runners matching the scope.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabListError: If the resource cannot be found
-        """
-        url = '/runners/all'
-        if scope is not None:
-            url += '?scope=' + scope
-        return self.gitlab._raw_list(url, self.obj_cls, **kwargs)
-
-
-class TeamMember(GitlabObject):
-    _url = '/user_teams/%(team_id)s/members'
-    canUpdate = False
-    requiredUrlAttrs = ['teamd_id']
-    requiredCreateAttrs = ['access_level']
-    shortPrintAttr = 'username'
-
-
-class Todo(GitlabObject):
-    _url = '/todos'
-    canGet = 'from_list'
-    canUpdate = False
-    canCreate = False
-    optionalListAttrs = ['action', 'author_id', 'project_id', 'state', 'type']
-
-
-class TodoManager(BaseManager):
-    obj_cls = Todo
-
-    def delete_all(self, **kwargs):
-        """Mark all the todos as done.
-
-        Raises:
-            GitlabConnectionError: If the server cannot be reached.
-            GitlabDeleteError: If the resource cannot be found
-
-        Returns:
-            The number of todos maked done.
-        """
-        url = '/todos'
-        r = self.gitlab._raw_delete(url, **kwargs)
-        raise_error_from_response(r, GitlabDeleteError)
-        return int(r.text)
-
-
-class ProjectManager(BaseManager):
-    obj_cls = Project
-
-    def search(self, query, **kwargs):
-        """Search projects by name.
-
-        .. note::
-
-           The search is only performed on the project name (not on the
-           namespace or the description). To perform a smarter search, use the
-           ``search`` argument of the ``list()`` method:
-
-           .. code-block:: python
-
-               gl.projects.list(search=your_search_string)
-
-        Args:
-            query (str): The query string to send to GitLab for the search.
-            all (bool): If True, return all the items, without pagination
-            **kwargs: Additional arguments to send to GitLab.
-
-        Returns:
-            list(gitlab.Gitlab.Project): A list of matching projects.
-        """
-        return self.gitlab._raw_list("/projects/search/" + query, Project,
-                                     **kwargs)
-
-    def all(self, **kwargs):
-        """List all the projects (need admin rights).
-
-        Args:
-            all (bool): If True, return all the items, without pagination
-            **kwargs: Additional arguments to send to GitLab.
-
-        Returns:
-            list(gitlab.Gitlab.Project): The list of projects.
-        """
-        return self.gitlab._raw_list("/projects/all", Project, **kwargs)
-
-    def owned(self, **kwargs):
-        """List owned projects.
-
-        Args:
-            all (bool): If True, return all the items, without pagination
-            **kwargs: Additional arguments to send to GitLab.
-
-        Returns:
-            list(gitlab.Gitlab.Project): The list of owned projects.
-        """
-        return self.gitlab._raw_list("/projects/owned", Project, **kwargs)
-
-    def starred(self, **kwargs):
-        """List starred projects.
-
-        Args:
-            all (bool): If True, return all the items, without pagination
-            **kwargs: Additional arguments to send to GitLab.
-
-        Returns:
-            list(gitlab.Gitlab.Project): The list of starred projects.
-        """
-        return self.gitlab._raw_list("/projects/starred", Project, **kwargs)
-
-
-class TeamMemberManager(BaseManager):
-    obj_cls = TeamMember
-
-
-class TeamProject(GitlabObject):
-    _url = '/user_teams/%(team_id)s/projects'
-    _constructorTypes = {'owner': 'User', 'namespace': 'Group'}
-    canUpdate = False
-    requiredCreateAttrs = ['greatest_access_level']
-    requiredUrlAttrs = ['team_id']
-    shortPrintAttr = 'name'
-
-
-class TeamProjectManager(BaseManager):
-    obj_cls = TeamProject
-
-
-class Team(GitlabObject):
-    _url = '/user_teams'
-    shortPrintAttr = 'name'
-    requiredCreateAttrs = ['name', 'path']
-    canUpdate = False
-    managers = (
-        ('members', TeamMemberManager, [('team_id', 'id')]),
-        ('projects', TeamProjectManager, [('team_id', 'id')]),
-    )
-
-
-class TeamManager(BaseManager):
-    obj_cls = Team
diff --git a/gitlab/py.typed b/gitlab/py.typed
new file mode 100644
index 000000000..e69de29bb
diff --git a/gitlab/tests/test_cli.py b/gitlab/tests/test_cli.py
deleted file mode 100644
index c32ad5018..000000000
--- a/gitlab/tests/test_cli.py
+++ /dev/null
@@ -1,95 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-#
-# Copyright (C) 2016 Gauvain Pocentek <gauvain@pocentek.net>
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Lesser General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Lesser General Public License for more details.
-#
-# You should have received a copy of the GNU Lesser General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from __future__ import print_function
-from __future__ import absolute_import
-
-import argparse
-
-import six
-try:
-    import unittest
-except ImportError:
-    import unittest2 as unittest
-
-from gitlab import cli
-
-
-class TestCLI(unittest.TestCase):
-    def test_what_to_cls(self):
-        self.assertEqual("Foo", cli._what_to_cls("foo"))
-        self.assertEqual("FooBar", cli._what_to_cls("foo-bar"))
-
-    def test_cls_to_what(self):
-        class Class(object):
-            pass
-
-        class TestClass(object):
-            pass
-
-        self.assertEqual("test-class", cli._cls_to_what(TestClass))
-        self.assertEqual("class", cli._cls_to_what(Class))
-
-    def test_die(self):
-        with self.assertRaises(SystemExit) as test:
-            cli._die("foobar")
-
-        self.assertEqual(test.exception.code, 1)
-
-    def test_extra_actions(self):
-        for cls, data in six.iteritems(cli.EXTRA_ACTIONS):
-            for key in data:
-                self.assertIsInstance(data[key], dict)
-
-    def test_parsing(self):
-        args = cli._parse_args(['-v', '-g', 'gl_id',
-                                '-c', 'foo.cfg', '-c', 'bar.cfg',
-                                'project', 'list'])
-        self.assertTrue(args.verbose)
-        self.assertEqual(args.gitlab, 'gl_id')
-        self.assertEqual(args.config_file, ['foo.cfg', 'bar.cfg'])
-        self.assertEqual(args.what, 'project')
-        self.assertEqual(args.action, 'list')
-
-    def test_parser(self):
-        parser = cli._build_parser()
-        subparsers = None
-        for action in parser._actions:
-            if type(action) == argparse._SubParsersAction:
-                subparsers = action
-                break
-        self.assertIsNotNone(subparsers)
-        self.assertIn('user', subparsers.choices)
-
-        user_subparsers = None
-        for action in subparsers.choices['user']._actions:
-            if type(action) == argparse._SubParsersAction:
-                user_subparsers = action
-                break
-        self.assertIsNotNone(user_subparsers)
-        self.assertIn('list', user_subparsers.choices)
-        self.assertIn('get', user_subparsers.choices)
-        self.assertIn('delete', user_subparsers.choices)
-        self.assertIn('update', user_subparsers.choices)
-        self.assertIn('create', user_subparsers.choices)
-        self.assertIn('block', user_subparsers.choices)
-        self.assertIn('unblock', user_subparsers.choices)
-
-        actions = user_subparsers.choices['create']._option_string_actions
-        self.assertFalse(actions['--twitter'].required)
-        self.assertTrue(actions['--username'].required)
diff --git a/gitlab/tests/test_config.py b/gitlab/tests/test_config.py
deleted file mode 100644
index 2b9cce412..000000000
--- a/gitlab/tests/test_config.py
+++ /dev/null
@@ -1,111 +0,0 @@
-# -*- coding: utf-8 -*-
-#
-# Copyright (C) 2016 Gauvain Pocentek <gauvain@pocentek.net>
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Lesser General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Lesser General Public License for more details.
-#
-# You should have received a copy of the GNU Lesser General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-try:
-    import unittest
-except ImportError:
-    import unittest2 as unittest
-
-import mock
-import six
-
-from gitlab import config
-
-
-valid_config = u"""[global]
-default = one
-ssl_verify = true
-timeout = 2
-
-[one]
-url = http://one.url
-private_token = ABCDEF
-
-[two]
-url = https://two.url
-private_token = GHIJKL
-ssl_verify = false
-timeout = 10
-"""
-
-no_default_config = u"""[global]
-[there]
-url = http://there.url
-private_token = ABCDEF
-"""
-
-missing_attr_config = u"""[global]
-[one]
-url = http://one.url
-
-[two]
-private_token = ABCDEF
-
-[three]
-meh = hem
-"""
-
-
-class TestConfigParser(unittest.TestCase):
-    @mock.patch('six.moves.builtins.open')
-    def test_invalid_id(self, m_open):
-        fd = six.StringIO(no_default_config)
-        fd.close = mock.Mock(return_value=None)
-        m_open.return_value = fd
-        self.assertRaises(config.GitlabIDError, config.GitlabConfigParser)
-
-        fd = six.StringIO(valid_config)
-        fd.close = mock.Mock(return_value=None)
-        m_open.return_value = fd
-        self.assertRaises(config.GitlabDataError,
-                          config.GitlabConfigParser,
-                          gitlab_id='not_there')
-
-    @mock.patch('six.moves.builtins.open')
-    def test_invalid_data(self, m_open):
-        fd = six.StringIO(missing_attr_config)
-        fd.close = mock.Mock(return_value=None)
-        m_open.return_value = fd
-        self.assertRaises(config.GitlabDataError, config.GitlabConfigParser,
-                          gitlab_id='one')
-        self.assertRaises(config.GitlabDataError, config.GitlabConfigParser,
-                          gitlab_id='two')
-        self.assertRaises(config.GitlabDataError, config.GitlabConfigParser,
-                          gitlab_id='three')
-
-    @mock.patch('six.moves.builtins.open')
-    def test_valid_data(self, m_open):
-        fd = six.StringIO(valid_config)
-        fd.close = mock.Mock(return_value=None)
-        m_open.return_value = fd
-
-        cp = config.GitlabConfigParser()
-        self.assertEqual("one", cp.gitlab_id)
-        self.assertEqual("http://one.url", cp.url)
-        self.assertEqual("ABCDEF", cp.token)
-        self.assertEqual(2, cp.timeout)
-        self.assertEqual(True, cp.ssl_verify)
-
-        fd = six.StringIO(valid_config)
-        fd.close = mock.Mock(return_value=None)
-        m_open.return_value = fd
-        cp = config.GitlabConfigParser(gitlab_id="two")
-        self.assertEqual("two", cp.gitlab_id)
-        self.assertEqual("https://two.url", cp.url)
-        self.assertEqual("GHIJKL", cp.token)
-        self.assertEqual(10, cp.timeout)
-        self.assertEqual(False, cp.ssl_verify)
diff --git a/gitlab/tests/test_gitlab.py b/gitlab/tests/test_gitlab.py
deleted file mode 100644
index 4adf07f5a..000000000
--- a/gitlab/tests/test_gitlab.py
+++ /dev/null
@@ -1,729 +0,0 @@
-# -*- coding: utf-8 -*-
-#
-# Copyright (C) 2014 Mika Mäenpää <mika.j.maenpaa@tut.fi>,
-#                    Tampere University of Technology
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Lesser General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Lesser General Public License for more details.
-#
-# You should have received a copy of the GNU Lesser General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from __future__ import print_function
-
-try:
-    import unittest
-except ImportError:
-    import unittest2 as unittest
-
-from httmock import HTTMock  # noqa
-from httmock import response  # noqa
-from httmock import urlmatch  # noqa
-
-import gitlab
-from gitlab import *  # noqa
-
-
-class TestSanitize(unittest.TestCase):
-    def test_do_nothing(self):
-        self.assertEqual(1, gitlab._sanitize(1))
-        self.assertEqual(1.5, gitlab._sanitize(1.5))
-        self.assertEqual("foo", gitlab._sanitize("foo"))
-
-    def test_slash(self):
-        self.assertEqual("foo%2Fbar", gitlab._sanitize("foo/bar"))
-
-    def test_dict(self):
-        source = {"url": "foo/bar", "id": 1}
-        expected = {"url": "foo%2Fbar", "id": 1}
-        self.assertEqual(expected, gitlab._sanitize(source))
-
-
-class TestGitlabRawMethods(unittest.TestCase):
-    def setUp(self):
-        self.gl = Gitlab("http://localhost", private_token="private_token",
-                         email="testuser@test.com", password="testpassword",
-                         ssl_verify=True)
-
-    @urlmatch(scheme="http", netloc="localhost", path="/api/v3/known_path",
-              method="get")
-    def resp_get(self, url, request):
-        headers = {'content-type': 'application/json'}
-        content = 'response'.encode("utf-8")
-        return response(200, content, headers, None, 5, request)
-
-    def test_raw_get_unknown_path(self):
-
-        @urlmatch(scheme="http", netloc="localhost",
-                  path="/api/v3/unknown_path",
-                  method="get")
-        def resp_cont(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"message": "message"}'.encode("utf-8")
-            return response(404, content, headers, None, 5, request)
-
-        with HTTMock(resp_cont):
-            resp = self.gl._raw_get("/unknown_path")
-            self.assertEqual(resp.status_code, 404)
-
-    def test_raw_get_without_kwargs(self):
-        with HTTMock(self.resp_get):
-            resp = self.gl._raw_get("/known_path")
-        self.assertEqual(resp.content, b'response')
-        self.assertEqual(resp.status_code, 200)
-
-    def test_raw_get_with_kwargs(self):
-        with HTTMock(self.resp_get):
-            resp = self.gl._raw_get("/known_path", sudo="testing")
-        self.assertEqual(resp.content, b'response')
-        self.assertEqual(resp.status_code, 200)
-
-    def test_raw_post(self):
-
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/known_path",
-                  method="post")
-        def resp_post(url, request):
-            headers = {'content-type': 'application/json'}
-            content = 'response'.encode("utf-8")
-            return response(200, content, headers, None, 5, request)
-
-        with HTTMock(resp_post):
-            resp = self.gl._raw_post("/known_path")
-        self.assertEqual(resp.content, b'response')
-        self.assertEqual(resp.status_code, 200)
-
-    def test_raw_post_unknown_path(self):
-
-        @urlmatch(scheme="http", netloc="localhost",
-                  path="/api/v3/unknown_path",
-                  method="post")
-        def resp_cont(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"message": "message"}'.encode("utf-8")
-            return response(404, content, headers, None, 5, request)
-
-        with HTTMock(resp_cont):
-            resp = self.gl._raw_post("/unknown_path")
-            self.assertEqual(resp.status_code, 404)
-
-    def test_raw_put(self):
-
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/known_path",
-                  method="put")
-        def resp_put(url, request):
-            headers = {'content-type': 'application/json'}
-            content = 'response'.encode("utf-8")
-            return response(200, content, headers, None, 5, request)
-
-        with HTTMock(resp_put):
-            resp = self.gl._raw_put("/known_path")
-        self.assertEqual(resp.content, b'response')
-        self.assertEqual(resp.status_code, 200)
-
-    def test_raw_put_unknown_path(self):
-
-        @urlmatch(scheme="http", netloc="localhost",
-                  path="/api/v3/unknown_path",
-                  method="put")
-        def resp_cont(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"message": "message"}'.encode("utf-8")
-            return response(404, content, headers, None, 5, request)
-
-        with HTTMock(resp_cont):
-            resp = self.gl._raw_put("/unknown_path")
-            self.assertEqual(resp.status_code, 404)
-
-    def test_raw_delete(self):
-
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/known_path",
-                  method="delete")
-        def resp_delete(url, request):
-            headers = {'content-type': 'application/json'}
-            content = 'response'.encode("utf-8")
-            return response(200, content, headers, None, 5, request)
-
-        with HTTMock(resp_delete):
-            resp = self.gl._raw_delete("/known_path")
-        self.assertEqual(resp.content, b'response')
-        self.assertEqual(resp.status_code, 200)
-
-    def test_raw_delete_unknown_path(self):
-
-        @urlmatch(scheme="http", netloc="localhost",
-                  path="/api/v3/unknown_path",
-                  method="delete")
-        def resp_cont(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"message": "message"}'.encode("utf-8")
-            return response(404, content, headers, None, 5, request)
-
-        with HTTMock(resp_cont):
-            resp = self.gl._raw_delete("/unknown_path")
-            self.assertEqual(resp.status_code, 404)
-
-
-class TestGitlabMethods(unittest.TestCase):
-    def setUp(self):
-        self.gl = Gitlab("http://localhost", private_token="private_token",
-                         email="testuser@test.com", password="testpassword",
-                         ssl_verify=True)
-
-    def test_list(self):
-        @urlmatch(scheme="http", netloc="localhost",
-                  path="/api/v3/projects/1/repository/branches", method="get")
-        def resp_cont(url, request):
-            headers = {'content-type': 'application/json'}
-            content = ('[{"branch_name": "testbranch", '
-                       '"project_id": 1, "ref": "a"}]').encode("utf-8")
-            return response(200, content, headers, None, 5, request)
-
-        with HTTMock(resp_cont):
-            data = self.gl.list(ProjectBranch, project_id=1, page=1,
-                                per_page=20)
-            self.assertEqual(len(data), 1)
-            data = data[0]
-            self.assertEqual(data.branch_name, "testbranch")
-            self.assertEqual(data.project_id, 1)
-            self.assertEqual(data.ref, "a")
-
-    def test_list_next_link(self):
-        @urlmatch(scheme="http", netloc="localhost",
-                  path='/api/v3/projects/1/repository/branches', method="get")
-        def resp_one(url, request):
-            """First request:
-
-            http://localhost/api/v3/projects/1/repository/branches?per_page=1
-            """
-            headers = {
-                'content-type': 'application/json',
-                'link': '<http://localhost/api/v3/projects/1/repository/branc'
-                'hes?page=2&per_page=0>; rel="next", <http://localhost/api/v3'
-                '/projects/1/repository/branches?page=2&per_page=0>; rel="las'
-                't", <http://localhost/api/v3/projects/1/repository/branches?'
-                'page=1&per_page=0>; rel="first"'
-            }
-            content = ('[{"branch_name": "otherbranch", '
-                       '"project_id": 1, "ref": "b"}]').encode("utf-8")
-            resp = response(200, content, headers, None, 5, request)
-            return resp
-
-        @urlmatch(scheme="http", netloc="localhost",
-                  path='/api/v3/projects/1/repository/branches', method="get",
-                  query=r'.*page=2.*')
-        def resp_two(url, request):
-            headers = {
-                'content-type': 'application/json',
-                'link': '<http://localhost/api/v3/projects/1/repository/branc'
-                'hes?page=1&per_page=0>; rel="prev", <http://localhost/api/v3'
-                '/projects/1/repository/branches?page=2&per_page=0>; rel="las'
-                't", <http://localhost/api/v3/projects/1/repository/branches?'
-                'page=1&per_page=0>; rel="first"'
-            }
-            content = ('[{"branch_name": "testbranch", '
-                       '"project_id": 1, "ref": "a"}]').encode("utf-8")
-            resp = response(200, content, headers, None, 5, request)
-            return resp
-
-        with HTTMock(resp_two, resp_one):
-            data = self.gl.list(ProjectBranch, project_id=1, per_page=1,
-                                all=True)
-            self.assertEqual(data[1].branch_name, "testbranch")
-            self.assertEqual(data[1].project_id, 1)
-            self.assertEqual(data[1].ref, "a")
-            self.assertEqual(data[0].branch_name, "otherbranch")
-            self.assertEqual(data[0].project_id, 1)
-            self.assertEqual(data[0].ref, "b")
-            self.assertEqual(len(data), 2)
-
-    def test_list_401(self):
-        @urlmatch(scheme="http", netloc="localhost",
-                  path="/api/v3/projects/1/repository/branches", method="get")
-        def resp_cont(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"message":"message"}'.encode("utf-8")
-            return response(401, content, headers, None, 5, request)
-
-        with HTTMock(resp_cont):
-            self.assertRaises(GitlabAuthenticationError, self.gl.list,
-                              ProjectBranch, project_id=1)
-
-    def test_list_unknown_error(self):
-        @urlmatch(scheme="http", netloc="localhost",
-                  path="/api/v3/projects/1/repository/branches", method="get")
-        def resp_cont(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"message":"message"}'.encode("utf-8")
-            return response(405, content, headers, None, 5, request)
-
-        with HTTMock(resp_cont):
-            self.assertRaises(GitlabListError, self.gl.list,
-                              ProjectBranch, project_id=1)
-
-    def test_list_kw_missing(self):
-        self.assertRaises(GitlabListError, self.gl.list, ProjectBranch)
-
-    def test_list_no_connection(self):
-        self.gl.set_url('http://localhost:66000')
-        self.assertRaises(GitlabConnectionError, self.gl.list, ProjectBranch,
-                          project_id=1)
-
-    def test_get(self):
-        @urlmatch(scheme="http", netloc="localhost",
-                  path="/api/v3/projects/1", method="get")
-        def resp_cont(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"name": "testproject"}'.encode("utf-8")
-            return response(200, content, headers, None, 5, request)
-
-        with HTTMock(resp_cont):
-            data = self.gl.get(Project, id=1)
-            expected = {"name": "testproject"}
-            self.assertEqual(expected, data)
-
-    def test_get_unknown_path(self):
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/groups/1",
-                  method="get")
-        def resp_cont(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"message": "message"}'.encode("utf-8")
-            return response(404, content, headers, None, 5, request)
-
-        with HTTMock(resp_cont):
-            self.assertRaises(GitlabGetError, self.gl.get, Group, 1)
-
-    def test_get_missing_kw(self):
-        self.assertRaises(GitlabGetError, self.gl.get, ProjectBranch)
-
-    def test_get_401(self):
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/projects/1",
-                  method="get")
-        def resp_cont(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"message": "message"}'.encode("utf-8")
-            return response(401, content, headers, None, 5, request)
-
-        with HTTMock(resp_cont):
-            self.assertRaises(GitlabAuthenticationError, self.gl.get,
-                              Project, 1)
-
-    def test_get_404(self):
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/projects/1",
-                  method="get")
-        def resp_cont(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"message": "message"}'.encode("utf-8")
-            return response(404, content, headers, None, 5, request)
-
-        with HTTMock(resp_cont):
-            self.assertRaises(GitlabGetError, self.gl.get,
-                              Project, 1)
-
-    def test_get_unknown_error(self):
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/projects/1",
-                  method="get")
-        def resp_cont(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"message": "message"}'.encode("utf-8")
-            return response(405, content, headers, None, 5, request)
-
-        with HTTMock(resp_cont):
-            self.assertRaises(GitlabGetError, self.gl.get,
-                              Project, 1)
-
-    def test_delete_from_object(self):
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/groups/1",
-                  method="delete")
-        def resp_delete_group(url, request):
-            headers = {'content-type': 'application/json'}
-            content = ''.encode("utf-8")
-            return response(200, content, headers, None, 5, request)
-
-        obj = Group(self.gl, data={"name": "testname", "id": 1})
-        with HTTMock(resp_delete_group):
-            data = self.gl.delete(obj)
-            self.assertIs(data, True)
-
-    def test_delete_from_invalid_class(self):
-        class InvalidClass(object):
-            pass
-
-        self.assertRaises(GitlabError, self.gl.delete, InvalidClass, 1)
-
-    def test_delete_from_class(self):
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/groups/1",
-                  method="delete")
-        def resp_delete_group(url, request):
-            headers = {'content-type': 'application/json'}
-            content = ''.encode("utf-8")
-            return response(200, content, headers, None, 5, request)
-
-        with HTTMock(resp_delete_group):
-            data = self.gl.delete(Group, 1)
-            self.assertIs(data, True)
-
-    def test_delete_unknown_path(self):
-        obj = Project(self.gl, data={"name": "testname", "id": 1})
-        obj._from_api = True
-
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/projects/1",
-                  method="delete")
-        def resp_cont(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"message": "message"}'.encode("utf-8")
-            return response(404, content, headers, None, 5, request)
-
-        with HTTMock(resp_cont):
-            self.assertRaises(GitlabDeleteError, self.gl.delete, obj)
-
-    def test_delete_401(self):
-        obj = Project(self.gl, data={"name": "testname", "id": 1})
-
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/projects/1",
-                  method="delete")
-        def resp_cont(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"message": "message"}'.encode("utf-8")
-            return response(401, content, headers, None, 5, request)
-
-        with HTTMock(resp_cont):
-            self.assertRaises(GitlabAuthenticationError, self.gl.delete, obj)
-
-    def test_delete_unknown_error(self):
-        obj = Project(self.gl, data={"name": "testname", "id": 1})
-
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/projects/1",
-                  method="delete")
-        def resp_cont(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"message": "message"}'.encode("utf-8")
-            return response(405, content, headers, None, 5, request)
-
-        with HTTMock(resp_cont):
-            self.assertRaises(GitlabDeleteError, self.gl.delete, obj)
-
-    def test_create(self):
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/projects",
-                  method="post")
-        def resp_create_project(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"name": "testname", "id": 1}'.encode("utf-8")
-            return response(201, content, headers, None, 5, request)
-
-        obj = Project(self.gl, data={"name": "testname"})
-
-        with HTTMock(resp_create_project):
-            data = self.gl.create(obj)
-            expected = {u"name": u"testname", u"id": 1}
-            self.assertEqual(expected, data)
-
-    def test_create_kw_missing(self):
-        obj = Group(self.gl, data={"name": "testgroup"})
-        self.assertRaises(GitlabCreateError, self.gl.create, obj)
-
-    def test_create_unknown_path(self):
-        obj = Project(self.gl, data={"name": "name"})
-        obj.id = 1
-        obj._from_api = True
-
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/projects/1",
-                  method="delete")
-        def resp_cont(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"message": "message"}'.encode("utf-8")
-            return response(404, content, headers, None, 5, request)
-
-        with HTTMock(resp_cont):
-            self.assertRaises(GitlabDeleteError, self.gl.delete, obj)
-
-    def test_create_401(self):
-        obj = Group(self.gl, data={"name": "testgroup", "path": "testpath"})
-
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/groups",
-                  method="post")
-        def resp_cont(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"message": "message"}'.encode("utf-8")
-            return response(401, content, headers, None, 5, request)
-
-        with HTTMock(resp_cont):
-            self.assertRaises(GitlabAuthenticationError, self.gl.create, obj)
-
-    def test_create_unknown_error(self):
-        obj = Group(self.gl, data={"name": "testgroup", "path": "testpath"})
-
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/groups",
-                  method="post")
-        def resp_cont(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"message": "message"}'.encode("utf-8")
-            return response(405, content, headers, None, 5, request)
-
-        with HTTMock(resp_cont):
-            self.assertRaises(GitlabCreateError, self.gl.create, obj)
-
-    def test_update(self):
-        obj = User(self.gl, data={"email": "testuser@testmail.com",
-                                  "password": "testpassword",
-                                  "name": u"testuser",
-                                  "username": "testusername",
-                                  "can_create_group": True,
-                                  "id": 1})
-
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/users/1",
-                  method="put")
-        def resp_cont(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"first": "return1"}'.encode("utf-8")
-            return response(200, content, headers, None, 5, request)
-
-        with HTTMock(resp_cont):
-            data = self.gl.update(obj)
-            expected = {"first": "return1"}
-            self.assertEqual(expected, data)
-
-    def test_update_kw_missing(self):
-        obj = Hook(self.gl, data={"name": "testgroup"})
-        self.assertRaises(GitlabUpdateError, self.gl.update, obj)
-
-    def test_update_401(self):
-        obj = Group(self.gl, data={"name": "testgroup", "path": "testpath",
-                                   "id": 1})
-
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/groups/1",
-                  method="put")
-        def resp_cont(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"message": "message"}'.encode("utf-8")
-            return response(401, content, headers, None, 5, request)
-
-        with HTTMock(resp_cont):
-            self.assertRaises(GitlabAuthenticationError, self.gl.update, obj)
-
-    def test_update_unknown_error(self):
-        obj = Group(self.gl, data={"name": "testgroup", "path": "testpath",
-                                   "id": 1})
-
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/groups/1",
-                  method="put")
-        def resp_cont(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"message": "message"}'.encode("utf-8")
-            return response(405, content, headers, None, 5, request)
-
-        with HTTMock(resp_cont):
-            self.assertRaises(GitlabUpdateError, self.gl.update, obj)
-
-    def test_update_unknown_path(self):
-        obj = Group(self.gl, data={"name": "testgroup", "path": "testpath",
-                                   "id": 1})
-
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/groups/1",
-                  method="put")
-        def resp_cont(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"message": "message"}'.encode("utf-8")
-            return response(404, content, headers, None, 5, request)
-
-        with HTTMock(resp_cont):
-            self.assertRaises(GitlabUpdateError, self.gl.update, obj)
-
-
-class TestGitlab(unittest.TestCase):
-
-    def setUp(self):
-        self.gl = Gitlab("http://localhost", private_token="private_token",
-                         email="testuser@test.com", password="testpassword",
-                         ssl_verify=True)
-
-    def test_set_url(self):
-        self.gl.set_url("http://new_url")
-        self.assertEqual(self.gl._url, "http://new_url/api/v3")
-
-    def test_set_token(self):
-        token = "newtoken"
-        expected = {"PRIVATE-TOKEN": token}
-        self.gl.set_token(token)
-        self.assertEqual(self.gl.private_token, token)
-        self.assertDictContainsSubset(expected, self.gl.headers)
-
-    def test_set_credentials(self):
-        email = "credentialuser@test.com"
-        password = "credentialpassword"
-        self.gl.set_credentials(email=email, password=password)
-        self.assertEqual(self.gl.email, email)
-        self.assertEqual(self.gl.password, password)
-
-    def test_credentials_auth_nopassword(self):
-        self.gl.set_credentials(email=None, password=None)
-        self.assertRaises(GitlabAuthenticationError, self.gl.credentials_auth)
-
-    def test_credentials_auth_notok(self):
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/session",
-                  method="post")
-        def resp_cont(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"message": "message"}'.encode("utf-8")
-            return response(404, content, headers, None, 5, request)
-
-        with HTTMock(resp_cont):
-            self.assertRaises(GitlabAuthenticationError,
-                              self.gl.credentials_auth)
-
-    def test_auth_with_credentials(self):
-        self.gl.set_token(None)
-        self.test_credentials_auth(callback=self.gl.auth)
-
-    def test_auth_with_token(self):
-        self.test_token_auth(callback=self.gl.auth)
-
-    def test_credentials_auth(self, callback=None):
-        if callback is None:
-            callback = self.gl.credentials_auth
-        token = "credauthtoken"
-        id_ = 1
-        expected = {"PRIVATE-TOKEN": token}
-
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/session",
-                  method="post")
-        def resp_cont(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{{"id": {0:d}, "private_token": "{1:s}"}}'.format(
-                id_, token).encode("utf-8")
-            return response(201, content, headers, None, 5, request)
-
-        with HTTMock(resp_cont):
-            callback()
-        self.assertEqual(self.gl.private_token, token)
-        self.assertDictContainsSubset(expected, self.gl.headers)
-        self.assertEqual(self.gl.user.id, id_)
-
-    def test_token_auth(self, callback=None):
-        if callback is None:
-            callback = self.gl.token_auth
-        name = "username"
-        id_ = 1
-
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/user",
-                  method="get")
-        def resp_cont(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{{"id": {0:d}, "username": "{1:s}"}}'.format(
-                id_, name).encode("utf-8")
-            return response(200, content, headers, None, 5, request)
-
-        with HTTMock(resp_cont):
-            callback()
-        self.assertEqual(self.gl.user.username, name)
-        self.assertEqual(self.gl.user.id, id_)
-        self.assertEqual(type(self.gl.user), CurrentUser)
-
-    def test_hooks(self):
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/hooks/1",
-                  method="get")
-        def resp_get_hook(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"url": "testurl", "id": 1}'.encode("utf-8")
-            return response(200, content, headers, None, 5, request)
-
-        with HTTMock(resp_get_hook):
-            data = self.gl.hooks.get(1)
-            self.assertEqual(type(data), Hook)
-            self.assertEqual(data.url, "testurl")
-            self.assertEqual(data.id, 1)
-
-    def test_projects(self):
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/projects/1",
-                  method="get")
-        def resp_get_project(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"name": "name", "id": 1}'.encode("utf-8")
-            return response(200, content, headers, None, 5, request)
-
-        with HTTMock(resp_get_project):
-            data = self.gl.projects.get(1)
-            self.assertEqual(type(data), Project)
-            self.assertEqual(data.name, "name")
-            self.assertEqual(data.id, 1)
-
-    def test_userprojects(self):
-        @urlmatch(scheme="http", netloc="localhost",
-                  path="/api/v3/projects/user/2", method="get")
-        def resp_get_userproject(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"name": "name", "id": 1, "user_id": 2}'.encode("utf-8")
-            return response(200, content, headers, None, 5, request)
-
-        with HTTMock(resp_get_userproject):
-            self.assertRaises(NotImplementedError, self.gl.user_projects.get,
-                              1, user_id=2)
-
-    def test_groups(self):
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/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.assertEqual(type(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/v3/issues",
-                  method="get")
-        def resp_get_issue(url, request):
-            headers = {'content-type': 'application/json'}
-            content = ('[{"name": "name", "id": 1}, '
-                       '{"name": "other_name", "id": 2}]')
-            content = content.encode("utf-8")
-            return response(200, content, headers, None, 5, request)
-
-        with HTTMock(resp_get_issue):
-            data = self.gl.issues.get(2)
-            self.assertEqual(data.id, 2)
-            self.assertEqual(data.name, 'other_name')
-
-    def test_users(self):
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/users/1",
-                  method="get")
-        def resp_get_user(url, request):
-            headers = {'content-type': 'application/json'}
-            content = ('{"name": "name", "id": 1, "password": "password", '
-                       '"username": "username", "email": "email"}')
-            content = content.encode("utf-8")
-            return response(200, content, headers, None, 5, request)
-
-        with HTTMock(resp_get_user):
-            user = self.gl.users.get(1)
-            self.assertEqual(type(user), User)
-            self.assertEqual(user.name, "name")
-            self.assertEqual(user.id, 1)
-
-    def test_teams(self):
-        @urlmatch(scheme="http", netloc="localhost",
-                  path="/api/v3/user_teams/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.teams.get(1)
-            self.assertEqual(type(data), Team)
-            self.assertEqual(data.name, "name")
-            self.assertEqual(data.path, "path")
-            self.assertEqual(data.id, 1)
diff --git a/gitlab/tests/test_gitlabobject.py b/gitlab/tests/test_gitlabobject.py
deleted file mode 100644
index 3bffb825d..000000000
--- a/gitlab/tests/test_gitlabobject.py
+++ /dev/null
@@ -1,491 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-#
-# Copyright (C) 2014 Mika Mäenpää <mika.j.maenpaa@tut.fi>
-#                    Tampere University of Technology
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Lesser General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Lesser General Public License for more details.
-#
-# You should have received a copy of the GNU Lesser General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from __future__ import print_function
-from __future__ import division
-from __future__ import absolute_import
-
-import json
-try:
-    import unittest
-except ImportError:
-    import unittest2 as unittest
-
-from httmock import HTTMock  # noqa
-from httmock import response  # noqa
-from httmock import urlmatch  # noqa
-
-from gitlab import *  # noqa
-
-
-@urlmatch(scheme="http", netloc="localhost", path="/api/v3/projects/1",
-          method="get")
-def resp_get_project(url, request):
-    headers = {'content-type': 'application/json'}
-    content = '{"name": "name", "id": 1}'.encode("utf-8")
-    return response(200, content, headers, None, 5, request)
-
-
-@urlmatch(scheme="http", netloc="localhost", path="/api/v3/projects",
-          method="get")
-def resp_list_project(url, request):
-    headers = {'content-type': 'application/json'}
-    content = '[{"name": "name", "id": 1}]'.encode("utf-8")
-    return response(200, content, headers, None, 5, request)
-
-
-@urlmatch(scheme="http", netloc="localhost", path="/api/v3/issues/1",
-          method="get")
-def resp_get_issue(url, request):
-    headers = {'content-type': 'application/json'}
-    content = '{"name": "name", "id": 1}'.encode("utf-8")
-    return response(200, content, headers, None, 5, request)
-
-
-@urlmatch(scheme="http", netloc="localhost", path="/api/v3/users/1",
-          method="put")
-def resp_update_user(url, request):
-    headers = {'content-type': 'application/json'}
-    content = ('{"name": "newname", "id": 1, "password": "password", '
-               '"username": "username", "email": "email"}').encode("utf-8")
-    return response(200, content, headers, None, 5, request)
-
-
-@urlmatch(scheme="http", netloc="localhost", path="/api/v3/projects",
-          method="post")
-def resp_create_project(url, request):
-    headers = {'content-type': 'application/json'}
-    content = '{"name": "testname", "id": 1}'.encode("utf-8")
-    return response(201, content, headers, None, 5, request)
-
-
-@urlmatch(scheme="http", netloc="localhost", path="/api/v3/groups/2/members",
-          method="post")
-def resp_create_groupmember(url, request):
-    headers = {'content-type': 'application/json'}
-    content = '{"access_level": 50, "id": 3}'.encode("utf-8")
-    return response(201, content, headers, None, 5, request)
-
-
-@urlmatch(scheme="http", netloc="localhost",
-          path="/api/v3/projects/2/snippets/3", method="get")
-def resp_get_projectsnippet(url, request):
-    headers = {'content-type': 'application/json'}
-    content = '{"title": "test", "id": 3}'.encode("utf-8")
-    return response(200, content, headers, None, 5, request)
-
-
-@urlmatch(scheme="http", netloc="localhost", path="/api/v3/groups/1",
-          method="delete")
-def resp_delete_group(url, request):
-    headers = {'content-type': 'application/json'}
-    content = ''.encode("utf-8")
-    return response(200, content, headers, None, 5, request)
-
-
-@urlmatch(scheme="http", netloc="localhost",
-          path="/api/v3/groups/2/projects/3",
-          method="post")
-def resp_transfer_project(url, request):
-    headers = {'content-type': 'application/json'}
-    content = ''.encode("utf-8")
-    return response(201, content, headers, None, 5, request)
-
-
-@urlmatch(scheme="http", netloc="localhost",
-          path="/api/v3/groups/2/projects/3",
-          method="post")
-def resp_transfer_project_fail(url, request):
-    headers = {'content-type': 'application/json'}
-    content = '{"message": "messagecontent"}'.encode("utf-8")
-    return response(400, content, headers, None, 5, request)
-
-
-@urlmatch(scheme="http", netloc="localhost",
-          path="/api/v3/projects/2/repository/branches/branchname/protect",
-          method="put")
-def resp_protect_branch(url, request):
-    headers = {'content-type': 'application/json'}
-    content = ''.encode("utf-8")
-    return response(200, content, headers, None, 5, request)
-
-
-@urlmatch(scheme="http", netloc="localhost",
-          path="/api/v3/projects/2/repository/branches/branchname/unprotect",
-          method="put")
-def resp_unprotect_branch(url, request):
-    headers = {'content-type': 'application/json'}
-    content = ''.encode("utf-8")
-    return response(200, content, headers, None, 5, request)
-
-
-@urlmatch(scheme="http", netloc="localhost",
-          path="/api/v3/projects/2/repository/branches/branchname/protect",
-          method="put")
-def resp_protect_branch_fail(url, request):
-    headers = {'content-type': 'application/json'}
-    content = '{"message": "messagecontent"}'.encode("utf-8")
-    return response(400, content, headers, None, 5, request)
-
-
-class TestGitlabObject(unittest.TestCase):
-
-    def setUp(self):
-        self.gl = Gitlab("http://localhost", private_token="private_token",
-                         email="testuser@test.com", password="testpassword",
-                         ssl_verify=True)
-
-    def test_json(self):
-        gl_object = CurrentUser(self.gl, data={"username": "testname"})
-        json_str = gl_object.json()
-        data = json.loads(json_str)
-        self.assertIn("id", data)
-        self.assertEqual(data["username"], "testname")
-        self.assertEqual(data["gitlab"]["url"], "http://localhost/api/v3")
-
-    def test_data_for_gitlab(self):
-        class FakeObj1(GitlabObject):
-            _url = '/fake1'
-            requiredCreateAttrs = ['create_req']
-            optionalCreateAttrs = ['create_opt']
-            requiredUpdateAttrs = ['update_req']
-            optionalUpdateAttrs = ['update_opt']
-
-        class FakeObj2(GitlabObject):
-            _url = '/fake2'
-            requiredCreateAttrs = ['create_req']
-            optionalCreateAttrs = ['create_opt']
-
-        obj1 = FakeObj1(self.gl, {'update_req': 1, 'update_opt': 1,
-                                  'create_req': 1, 'create_opt': 1})
-        obj2 = FakeObj2(self.gl, {'create_req': 1, 'create_opt': 1})
-
-        obj1_data = json.loads(obj1._data_for_gitlab())
-        self.assertIn('create_req', obj1_data)
-        self.assertIn('create_opt', obj1_data)
-        self.assertNotIn('update_req', obj1_data)
-        self.assertNotIn('update_opt', obj1_data)
-        self.assertNotIn('gitlab', obj1_data)
-
-        obj1_data = json.loads(obj1._data_for_gitlab(update=True))
-        self.assertNotIn('create_req', obj1_data)
-        self.assertNotIn('create_opt', obj1_data)
-        self.assertIn('update_req', obj1_data)
-        self.assertIn('update_opt', obj1_data)
-
-        obj1_data = json.loads(obj1._data_for_gitlab(
-            extra_parameters={'foo': 'bar'}))
-        self.assertIn('foo', obj1_data)
-        self.assertEqual(obj1_data['foo'], 'bar')
-
-        obj2_data = json.loads(obj2._data_for_gitlab(update=True))
-        self.assertIn('create_req', obj2_data)
-        self.assertIn('create_opt', obj2_data)
-
-    def test_list_not_implemented(self):
-        self.assertRaises(NotImplementedError, CurrentUser.list, self.gl)
-
-    def test_list(self):
-        with HTTMock(resp_list_project):
-            data = Project.list(self.gl, id=1)
-            self.assertEqual(type(data), list)
-            self.assertEqual(len(data), 1)
-            self.assertEqual(type(data[0]), Project)
-            self.assertEqual(data[0].name, "name")
-            self.assertEqual(data[0].id, 1)
-
-    def test_create_cantcreate(self):
-        gl_object = CurrentUser(self.gl, data={"username": "testname"})
-        self.assertRaises(NotImplementedError, gl_object._create)
-
-    def test_create(self):
-        obj = Project(self.gl, data={"name": "testname"})
-        with HTTMock(resp_create_project):
-            obj._create()
-            self.assertEqual(obj.id, 1)
-
-    def test_create_with_kw(self):
-        obj = GroupMember(self.gl, data={"access_level": 50, "user_id": 3},
-                          group_id=2)
-        with HTTMock(resp_create_groupmember):
-            obj._create()
-            self.assertEqual(obj.id, 3)
-            self.assertEqual(obj.group_id, 2)
-            self.assertEqual(obj.user_id, 3)
-            self.assertEqual(obj.access_level, 50)
-
-    def test_get_with_kw(self):
-        with HTTMock(resp_get_projectsnippet):
-            obj = ProjectSnippet(self.gl, data=3, project_id=2)
-        self.assertEqual(obj.id, 3)
-        self.assertEqual(obj.project_id, 2)
-        self.assertEqual(obj.title, "test")
-
-    def test_create_cantupdate(self):
-        gl_object = CurrentUser(self.gl, data={"username": "testname"})
-        self.assertRaises(NotImplementedError, gl_object._update)
-
-    def test_update(self):
-        obj = User(self.gl, data={"name": "testname", "email": "email",
-                                  "password": "password", "id": 1,
-                                  "username": "username"})
-        self.assertEqual(obj.name, "testname")
-        obj.name = "newname"
-        with HTTMock(resp_update_user):
-            obj._update()
-            self.assertEqual(obj.name, "newname")
-
-    def test_save_with_id(self):
-        obj = User(self.gl, data={"name": "testname", "email": "email",
-                                  "password": "password", "id": 1,
-                                  "username": "username"})
-        self.assertEqual(obj.name, "testname")
-        obj._from_api = True
-        obj.name = "newname"
-        with HTTMock(resp_update_user):
-            obj.save()
-            self.assertEqual(obj.name, "newname")
-
-    def test_save_without_id(self):
-        obj = Project(self.gl, data={"name": "testname"})
-        with HTTMock(resp_create_project):
-            obj.save()
-            self.assertEqual(obj.id, 1)
-
-    def test_delete(self):
-        obj = Group(self.gl, data={"name": "testname", "id": 1})
-        obj._from_api = True
-        with HTTMock(resp_delete_group):
-            data = obj.delete()
-            self.assertIs(data, True)
-
-    def test_delete_with_no_id(self):
-        obj = Group(self.gl, data={"name": "testname"})
-        self.assertRaises(GitlabDeleteError, obj.delete)
-
-    def test_delete_cant_delete(self):
-        obj = CurrentUser(self.gl, data={"name": "testname", "id": 1})
-        self.assertRaises(NotImplementedError, obj.delete)
-
-    def test_set_from_dict_BooleanTrue(self):
-        obj = Project(self.gl, data={"name": "testname"})
-        data = {"issues_enabled": True}
-        obj._set_from_dict(data)
-        self.assertIs(obj.issues_enabled, True)
-
-    def test_set_from_dict_BooleanFalse(self):
-        obj = Project(self.gl, data={"name": "testname"})
-        data = {"issues_enabled": False}
-        obj._set_from_dict(data)
-        self.assertIs(obj.issues_enabled, False)
-
-    def test_set_from_dict_None(self):
-        obj = Project(self.gl, data={"name": "testname"})
-        data = {"issues_enabled": None}
-        obj._set_from_dict(data)
-        self.assertIsNone(obj.issues_enabled)
-
-
-class TestGroup(unittest.TestCase):
-    def setUp(self):
-        self.gl = Gitlab("http://localhost", private_token="private_token",
-                         email="testuser@test.com", password="testpassword",
-                         ssl_verify=True)
-
-    def test_transfer_project(self):
-        obj = Group(self.gl, data={"name": "testname", "path": "testpath",
-                                   "id": 2})
-        with HTTMock(resp_transfer_project):
-            obj.transfer_project(3)
-
-    def test_transfer_project_fail(self):
-        obj = Group(self.gl, data={"name": "testname", "path": "testpath",
-                                   "id": 2})
-        with HTTMock(resp_transfer_project_fail):
-            self.assertRaises(GitlabTransferProjectError,
-                              obj.transfer_project, 3)
-
-
-class TestProjectBranch(unittest.TestCase):
-    def setUp(self):
-        self.gl = Gitlab("http://localhost", private_token="private_token",
-                         email="testuser@test.com", password="testpassword",
-                         ssl_verify=True)
-        self.obj = ProjectBranch(self.gl, data={"name": "branchname",
-                                                "ref": "ref_name", "id": 3,
-                                                "project_id": 2})
-
-    def test_protect(self):
-        self.assertRaises(AttributeError, getattr, self.obj, 'protected')
-        with HTTMock(resp_protect_branch):
-            self.obj.protect(True)
-            self.assertIs(self.obj.protected, True)
-
-    def test_protect_unprotect(self):
-        self.obj.protected = True
-        with HTTMock(resp_unprotect_branch):
-            self.obj.protect(False)
-            self.assertRaises(AttributeError, getattr, self.obj, 'protected')
-
-    def test_protect_unprotect_again(self):
-        self.assertRaises(AttributeError, getattr, self.obj, 'protected')
-        with HTTMock(resp_protect_branch):
-            self.obj.protect(True)
-            self.assertIs(self.obj.protected, True)
-        self.assertEqual(True, self.obj.protected)
-        with HTTMock(resp_unprotect_branch):
-            self.obj.protect(False)
-            self.assertRaises(AttributeError, getattr, self.obj, 'protected')
-
-    def test_protect_protect_fail(self):
-        with HTTMock(resp_protect_branch_fail):
-            self.assertRaises(GitlabProtectError, self.obj.protect)
-
-    def test_unprotect(self):
-        self.obj.protected = True
-        with HTTMock(resp_unprotect_branch):
-            self.obj.unprotect()
-            self.assertRaises(AttributeError, getattr, self.obj, 'protected')
-
-
-class TestProjectCommit(unittest.TestCase):
-    def setUp(self):
-        self.gl = Gitlab("http://localhost", private_token="private_token",
-                         email="testuser@test.com", password="testpassword",
-                         ssl_verify=True)
-        self.obj = ProjectCommit(self.gl, data={"id": 3, "project_id": 2})
-
-    @urlmatch(scheme="http", netloc="localhost",
-              path="/api/v3/projects/2/repository/commits/3/diff",
-              method="get")
-    def resp_diff(self, url, request):
-        headers = {'content-type': 'application/json'}
-        content = '{"json": 2 }'.encode("utf-8")
-        return response(200, content, headers, None, 5, request)
-
-    @urlmatch(scheme="http", netloc="localhost",
-              path="/api/v3/projects/2/repository/commits/3/diff",
-              method="get")
-    def resp_diff_fail(self, url, request):
-        headers = {'content-type': 'application/json'}
-        content = '{"message": "messagecontent" }'.encode("utf-8")
-        return response(400, content, headers, None, 5, request)
-
-    @urlmatch(scheme="http", netloc="localhost",
-              path="/api/v3/projects/2/repository/blobs/3",
-              method="get")
-    def resp_blob(self, url, request):
-        headers = {'content-type': 'application/json'}
-        content = 'blob'.encode("utf-8")
-        return response(200, content, headers, None, 5, request)
-
-    @urlmatch(scheme="http", netloc="localhost",
-              path="/api/v3/projects/2/repository/blobs/3",
-              method="get")
-    def resp_blob_fail(self, url, request):
-        headers = {'content-type': 'application/json'}
-        content = '{"message": "messagecontent" }'.encode("utf-8")
-        return response(400, content, headers, None, 5, request)
-
-    def test_diff(self):
-        with HTTMock(self.resp_diff):
-            data = {"json": 2}
-            diff = self.obj.diff()
-            self.assertEqual(diff, data)
-
-    def test_diff_fail(self):
-        with HTTMock(self.resp_diff_fail):
-            self.assertRaises(GitlabGetError, self.obj.diff)
-
-    def test_blob(self):
-        with HTTMock(self.resp_blob):
-            blob = self.obj.blob("testing")
-            self.assertEqual(blob, b'blob')
-
-    def test_blob_fail(self):
-        with HTTMock(self.resp_blob_fail):
-            self.assertRaises(GitlabGetError, self.obj.blob, "testing")
-
-
-class TestProjectSnippet(unittest.TestCase):
-    def setUp(self):
-        self.gl = Gitlab("http://localhost", private_token="private_token",
-                         email="testuser@test.com", password="testpassword",
-                         ssl_verify=True)
-        self.obj = ProjectSnippet(self.gl, data={"id": 3, "project_id": 2})
-
-    @urlmatch(scheme="http", netloc="localhost",
-              path="/api/v3/projects/2/snippets/3/raw",
-              method="get")
-    def resp_content(self, url, request):
-        headers = {'content-type': 'application/json'}
-        content = 'content'.encode("utf-8")
-        return response(200, content, headers, None, 5, request)
-
-    @urlmatch(scheme="http", netloc="localhost",
-              path="/api/v3/projects/2/snippets/3/raw",
-              method="get")
-    def resp_content_fail(self, url, request):
-        headers = {'content-type': 'application/json'}
-        content = '{"message": "messagecontent" }'.encode("utf-8")
-        return response(400, content, headers, None, 5, request)
-
-    def test_content(self):
-        with HTTMock(self.resp_content):
-            data = b'content'
-            content = self.obj.content()
-            self.assertEqual(content, data)
-
-    def test_blob_fail(self):
-        with HTTMock(self.resp_content_fail):
-            self.assertRaises(GitlabGetError, self.obj.content)
-
-
-class TestSnippet(unittest.TestCase):
-    def setUp(self):
-        self.gl = Gitlab("http://localhost", private_token="private_token",
-                         email="testuser@test.com", password="testpassword",
-                         ssl_verify=True)
-        self.obj = Snippet(self.gl, data={"id": 3})
-
-    @urlmatch(scheme="http", netloc="localhost",
-              path="/api/v3/snippets/3/raw",
-              method="get")
-    def resp_content(self, url, request):
-        headers = {'content-type': 'application/json'}
-        content = 'content'.encode("utf-8")
-        return response(200, content, headers, None, 5, request)
-
-    @urlmatch(scheme="http", netloc="localhost",
-              path="/api/v3/snippets/3/raw",
-              method="get")
-    def resp_content_fail(self, url, request):
-        headers = {'content-type': 'application/json'}
-        content = '{"message": "messagecontent" }'.encode("utf-8")
-        return response(400, content, headers, None, 5, request)
-
-    def test_content(self):
-        with HTTMock(self.resp_content):
-            data = b'content'
-            content = self.obj.raw()
-            self.assertEqual(content, data)
-
-    def test_blob_fail(self):
-        with HTTMock(self.resp_content_fail):
-            self.assertRaises(GitlabGetError, self.obj.raw)
diff --git a/gitlab/tests/test_manager.py b/gitlab/tests/test_manager.py
deleted file mode 100644
index 59987a7a8..000000000
--- a/gitlab/tests/test_manager.py
+++ /dev/null
@@ -1,308 +0,0 @@
-# -*- coding: utf-8 -*-
-#
-# Copyright (C) 2016 Gauvain Pocentek <gauvain@pocentek.net>
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Lesser General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Lesser General Public License for more details.
-#
-# You should have received a copy of the GNU Lesser General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-try:
-    import unittest
-except ImportError:
-    import unittest2 as unittest
-
-from httmock import HTTMock  # noqa
-from httmock import response  # noqa
-from httmock import urlmatch  # noqa
-
-from gitlab import *  # noqa
-from gitlab.objects import BaseManager  # noqa
-
-
-class FakeChildObject(GitlabObject):
-    _url = "/fake/%(parent_id)s/fakechild"
-    requiredCreateAttrs = ['name']
-    requiredUrlAttrs = ['parent_id']
-
-
-class FakeChildManager(BaseManager):
-    obj_cls = FakeChildObject
-
-
-class FakeObject(GitlabObject):
-    _url = "/fake"
-    requiredCreateAttrs = ['name']
-    managers = [('children', FakeChildManager, [('parent_id', 'id')])]
-
-
-class FakeObjectManager(BaseManager):
-    obj_cls = FakeObject
-
-
-class TestGitlabManager(unittest.TestCase):
-    def setUp(self):
-        self.gitlab = Gitlab("http://localhost", private_token="private_token",
-                             email="testuser@test.com",
-                             password="testpassword", ssl_verify=True)
-
-    def test_set_parent_args(self):
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/fake",
-                  method="POST")
-        def resp_create(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"id": 1, "name": "name"}'.encode("utf-8")
-            return response(201, content, headers, None, 5, request)
-
-        mgr = FakeChildManager(self.gitlab)
-        args = mgr._set_parent_args(name="name")
-        self.assertEqual(args, {"name": "name"})
-
-        with HTTMock(resp_create):
-            o = FakeObjectManager(self.gitlab).create({"name": "name"})
-            args = o.children._set_parent_args(name="name")
-            self.assertEqual(args, {"name": "name", "parent_id": 1})
-
-    def test_constructor(self):
-        self.assertRaises(AttributeError, BaseManager, self.gitlab)
-
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/fake/1",
-                  method="get")
-        def resp_get(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"id": 1, "name": "fake_name"}'.encode("utf-8")
-            return response(200, content, headers, None, 5, request)
-
-        with HTTMock(resp_get):
-            mgr = FakeObjectManager(self.gitlab)
-            fake_obj = mgr.get(1)
-            self.assertEqual(fake_obj.id, 1)
-            self.assertEqual(fake_obj.name, "fake_name")
-            self.assertEqual(mgr.gitlab, self.gitlab)
-            self.assertEqual(mgr.args, [])
-            self.assertEqual(mgr.parent, None)
-
-            self.assertIsInstance(fake_obj.children, FakeChildManager)
-            self.assertEqual(fake_obj.children.gitlab, self.gitlab)
-            self.assertEqual(fake_obj.children.parent, fake_obj)
-            self.assertEqual(len(fake_obj.children.args), 1)
-
-            fake_child = fake_obj.children.get(1)
-            self.assertEqual(fake_child.id, 1)
-            self.assertEqual(fake_child.name, "fake_name")
-
-    def test_get(self):
-        mgr = FakeObjectManager(self.gitlab)
-        FakeObject.canGet = False
-        self.assertRaises(NotImplementedError, mgr.get, 1)
-
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/fake/1",
-                  method="get")
-        def resp_get(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '{"id": 1, "name": "fake_name"}'.encode("utf-8")
-            return response(200, content, headers, None, 5, request)
-
-        with HTTMock(resp_get):
-            FakeObject.canGet = True
-            mgr = FakeObjectManager(self.gitlab)
-            fake_obj = mgr.get(1)
-            self.assertIsInstance(fake_obj, FakeObject)
-            self.assertEqual(fake_obj.id, 1)
-            self.assertEqual(fake_obj.name, "fake_name")
-
-    def test_list(self):
-        mgr = FakeObjectManager(self.gitlab)
-        FakeObject.canList = False
-        self.assertRaises(NotImplementedError, mgr.list)
-
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/fake",
-                  method="get")
-        def resp_get(url, request):
-            headers = {'content-type': 'application/json'}
-            content = ('[{"id": 1, "name": "fake_name1"},'
-                       '{"id": 2, "name": "fake_name2"}]')
-            content = content.encode("utf-8")
-            return response(200, content, headers, None, 5, request)
-
-        with HTTMock(resp_get):
-            FakeObject.canList = True
-            mgr = FakeObjectManager(self.gitlab)
-            fake_list = mgr.list()
-            self.assertEqual(len(fake_list), 2)
-            self.assertIsInstance(fake_list[0], FakeObject)
-            self.assertEqual(fake_list[0].id, 1)
-            self.assertEqual(fake_list[0].name, "fake_name1")
-            self.assertIsInstance(fake_list[1], FakeObject)
-            self.assertEqual(fake_list[1].id, 2)
-            self.assertEqual(fake_list[1].name, "fake_name2")
-
-    def test_create(self):
-        mgr = FakeObjectManager(self.gitlab)
-        FakeObject.canCreate = False
-        self.assertRaises(NotImplementedError, mgr.create, {'name': 'name'})
-
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/fake",
-                  method="post")
-        def resp_post(url, request):
-            headers = {'content-type': 'application/json'}
-            data = '{"name": "fake_name"}'
-            content = '{"id": 1, "name": "fake_name"}'.encode("utf-8")
-            return response(201, content, headers, data, 5, request)
-
-        with HTTMock(resp_post):
-            FakeObject.canCreate = True
-            mgr = FakeObjectManager(self.gitlab)
-            fake_obj = mgr.create({'name': 'fake_name'})
-            self.assertIsInstance(fake_obj, FakeObject)
-            self.assertEqual(fake_obj.id, 1)
-            self.assertEqual(fake_obj.name, "fake_name")
-
-    def test_project_manager_owned(self):
-        mgr = ProjectManager(self.gitlab)
-
-        @urlmatch(scheme="http", netloc="localhost",
-                  path="/api/v3/projects/owned", method="get")
-        def resp_get_all(url, request):
-            headers = {'content-type': 'application/json'}
-            content = ('[{"name": "name1", "id": 1}, '
-                       '{"name": "name2", "id": 2}]')
-            content = content.encode("utf-8")
-            return response(200, content, headers, None, 5, request)
-
-        with HTTMock(resp_get_all):
-            data = mgr.owned()
-            self.assertEqual(type(data), list)
-            self.assertEqual(2, len(data))
-            self.assertEqual(type(data[0]), Project)
-            self.assertEqual(type(data[1]), Project)
-            self.assertEqual(data[0].name, "name1")
-            self.assertEqual(data[1].name, "name2")
-            self.assertEqual(data[0].id, 1)
-            self.assertEqual(data[1].id, 2)
-
-    def test_project_manager_all(self):
-        mgr = ProjectManager(self.gitlab)
-
-        @urlmatch(scheme="http", netloc="localhost",
-                  path="/api/v3/projects/all", method="get")
-        def resp_get_all(url, request):
-            headers = {'content-type': 'application/json'}
-            content = ('[{"name": "name1", "id": 1}, '
-                       '{"name": "name2", "id": 2}]')
-            content = content.encode("utf-8")
-            return response(200, content, headers, None, 5, request)
-
-        with HTTMock(resp_get_all):
-            data = mgr.all()
-            self.assertEqual(type(data), list)
-            self.assertEqual(2, len(data))
-            self.assertEqual(type(data[0]), Project)
-            self.assertEqual(type(data[1]), Project)
-            self.assertEqual(data[0].name, "name1")
-            self.assertEqual(data[1].name, "name2")
-            self.assertEqual(data[0].id, 1)
-            self.assertEqual(data[1].id, 2)
-
-    def test_project_manager_search(self):
-        mgr = ProjectManager(self.gitlab)
-
-        @urlmatch(scheme="http", netloc="localhost",
-                  path="/api/v3/projects/search/foo", method="get")
-        def resp_get_all(url, request):
-            headers = {'content-type': 'application/json'}
-            content = ('[{"name": "foo1", "id": 1}, '
-                       '{"name": "foo2", "id": 2}]')
-            content = content.encode("utf-8")
-            return response(200, content, headers, None, 5, request)
-
-        with HTTMock(resp_get_all):
-            data = mgr.search('foo')
-            self.assertEqual(type(data), list)
-            self.assertEqual(2, len(data))
-            self.assertEqual(type(data[0]), Project)
-            self.assertEqual(type(data[1]), Project)
-            self.assertEqual(data[0].name, "foo1")
-            self.assertEqual(data[1].name, "foo2")
-            self.assertEqual(data[0].id, 1)
-            self.assertEqual(data[1].id, 2)
-
-    def test_user_manager_search(self):
-        mgr = UserManager(self.gitlab)
-
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/users",
-                  query="search=foo", method="get")
-        def resp_get_search(url, request):
-            headers = {'content-type': 'application/json'}
-            content = ('[{"name": "foo1", "id": 1}, '
-                       '{"name": "foo2", "id": 2}]')
-            content = content.encode("utf-8")
-            return response(200, content, headers, None, 5, request)
-
-        with HTTMock(resp_get_search):
-            data = mgr.search('foo')
-            self.assertEqual(type(data), list)
-            self.assertEqual(2, len(data))
-            self.assertEqual(type(data[0]), User)
-            self.assertEqual(type(data[1]), User)
-            self.assertEqual(data[0].name, "foo1")
-            self.assertEqual(data[1].name, "foo2")
-            self.assertEqual(data[0].id, 1)
-            self.assertEqual(data[1].id, 2)
-
-    def test_user_manager_get_by_username(self):
-        mgr = UserManager(self.gitlab)
-
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/users",
-                  query="username=foo", method="get")
-        def resp_get_username(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '[{"name": "foo", "id": 1}]'.encode("utf-8")
-            return response(200, content, headers, None, 5, request)
-
-        with HTTMock(resp_get_username):
-            data = mgr.get_by_username('foo')
-            self.assertEqual(type(data), User)
-            self.assertEqual(data.name, "foo")
-            self.assertEqual(data.id, 1)
-
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/users",
-                  query="username=foo", method="get")
-        def resp_get_username_nomatch(url, request):
-            headers = {'content-type': 'application/json'}
-            content = '[]'.encode("utf-8")
-            return response(200, content, headers, None, 5, request)
-
-        with HTTMock(resp_get_username_nomatch):
-            self.assertRaises(GitlabGetError, mgr.get_by_username, 'foo')
-
-    def test_group_manager_search(self):
-        mgr = GroupManager(self.gitlab)
-
-        @urlmatch(scheme="http", netloc="localhost", path="/api/v3/groups",
-                  query="search=foo", method="get")
-        def resp_get_search(url, request):
-            headers = {'content-type': 'application/json'}
-            content = ('[{"name": "foo1", "id": 1}, '
-                       '{"name": "foo2", "id": 2}]')
-            content = content.encode("utf-8")
-            return response(200, content, headers, None, 5, request)
-
-        with HTTMock(resp_get_search):
-            data = mgr.search('foo')
-            self.assertEqual(type(data), list)
-            self.assertEqual(2, len(data))
-            self.assertEqual(type(data[0]), Group)
-            self.assertEqual(type(data[1]), Group)
-            self.assertEqual(data[0].name, "foo1")
-            self.assertEqual(data[1].name, "foo2")
-            self.assertEqual(data[0].id, 1)
-            self.assertEqual(data[1].id, 2)
diff --git a/gitlab/types.py b/gitlab/types.py
new file mode 100644
index 000000000..d0e8d3952
--- /dev/null
+++ b/gitlab/types.py
@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+import dataclasses
+from typing import Any, TYPE_CHECKING
+
+
+@dataclasses.dataclass(frozen=True)
+class RequiredOptional:
+    required: tuple[str, ...] = ()
+    optional: tuple[str, ...] = ()
+    exclusive: tuple[str, ...] = ()
+
+    def validate_attrs(
+        self, *, data: dict[str, Any], excludes: list[str] | None = None
+    ) -> None:
+        if excludes is None:
+            excludes = []
+
+        if self.required:
+            required = [k for k in self.required if k not in excludes]
+            missing = [attr for attr in required if attr not in data]
+            if missing:
+                raise AttributeError(f"Missing attributes: {', '.join(missing)}")
+
+        if self.exclusive:
+            exclusives = [attr for attr in data if attr in self.exclusive]
+            if len(exclusives) > 1:
+                raise AttributeError(
+                    f"Provide only one of these attributes: {', '.join(exclusives)}"
+                )
+            if not exclusives:
+                raise AttributeError(
+                    f"Must provide one of these attributes: "
+                    f"{', '.join(self.exclusive)}"
+                )
+
+
+class GitlabAttribute:
+    def __init__(self, value: Any = None) -> None:
+        self._value = value
+
+    def get(self) -> Any:
+        return self._value
+
+    def set_from_cli(self, cli_value: Any) -> None:
+        self._value = cli_value
+
+    def get_for_api(self, *, key: str) -> tuple[str, Any]:
+        return (key, self._value)
+
+
+class _ListArrayAttribute(GitlabAttribute):
+    """Helper class to support `list` / `array` types."""
+
+    def set_from_cli(self, cli_value: str) -> None:
+        if not cli_value.strip():
+            self._value = []
+        else:
+            self._value = [item.strip() for item in cli_value.split(",")]
+
+    def get_for_api(self, *, key: str) -> tuple[str, str]:
+        # Do not comma-split single value passed as string
+        if isinstance(self._value, str):
+            return (key, self._value)
+
+        if TYPE_CHECKING:
+            assert isinstance(self._value, list)
+        return (key, ",".join([str(x) for x in self._value]))
+
+
+class ArrayAttribute(_ListArrayAttribute):
+    """To support `array` types as documented in
+    https://docs.gitlab.com/ee/api/#array"""
+
+    def get_for_api(self, *, key: str) -> tuple[str, Any]:
+        if isinstance(self._value, str):
+            return (f"{key}[]", self._value)
+
+        if TYPE_CHECKING:
+            assert isinstance(self._value, list)
+        return (f"{key}[]", self._value)
+
+
+class CommaSeparatedListAttribute(_ListArrayAttribute):
+    """For values which are sent to the server as a Comma Separated Values
+    (CSV) string.  We allow them to be specified as a list and we convert it
+    into a CSV"""
+
+
+class LowercaseStringAttribute(GitlabAttribute):
+    def get_for_api(self, *, key: str) -> tuple[str, str]:
+        return (key, str(self._value).lower())
+
+
+class FileAttribute(GitlabAttribute):
+    @staticmethod
+    def get_file_name(attr_name: str | None = None) -> str | None:
+        return attr_name
+
+
+class ImageAttribute(FileAttribute):
+    @staticmethod
+    def get_file_name(attr_name: str | None = None) -> str:
+        return f"{attr_name}.png" if attr_name else "image.png"
diff --git a/gitlab/utils.py b/gitlab/utils.py
index bd9c2757e..bf37e09a5 100644
--- a/gitlab/utils.py
+++ b/gitlab/utils.py
@@ -1,27 +1,82 @@
-# -*- coding: utf-8 -*-
-#
-# Copyright (C) 2016 Gauvain Pocentek <gauvain@pocentek.net>
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Lesser General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Lesser General Public License for more details.
-#
-# You should have received a copy of the GNU Lesser General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-class _StdoutStream(object):
-    def __call__(self, chunk):
+from __future__ import annotations
+
+import dataclasses
+import email.message
+import logging
+import pathlib
+import time
+import traceback
+import urllib.parse
+import warnings
+from collections.abc import Iterator, MutableMapping
+from typing import Any, Callable, Literal
+
+import requests
+
+from gitlab import const, types
+
+
+class _StdoutStream:
+    def __call__(self, chunk: Any) -> None:
         print(chunk)
 
 
-def response_content(response, streamed, action, chunk_size):
+def get_base_url(url: str | None = None) -> str:
+    """Return the base URL with the trailing slash stripped.
+    If the URL is a Falsy value, return the default URL.
+    Returns:
+        The base URL
+    """
+    if not url:
+        return const.DEFAULT_URL
+
+    return url.rstrip("/")
+
+
+def get_content_type(content_type: str | None) -> str:
+    message = email.message.Message()
+    if content_type is not None:
+        message["content-type"] = content_type
+
+    return message.get_content_type()
+
+
+class MaskingFormatter(logging.Formatter):
+    """A logging formatter that can mask credentials"""
+
+    def __init__(
+        self,
+        fmt: str | None = logging.BASIC_FORMAT,
+        datefmt: str | None = None,
+        style: Literal["%", "{", "$"] = "%",
+        validate: bool = True,
+        masked: str | None = None,
+    ) -> None:
+        super().__init__(fmt, datefmt, style, validate)
+        self.masked = masked
+
+    def _filter(self, entry: str) -> str:
+        if not self.masked:
+            return entry
+
+        return entry.replace(self.masked, "[MASKED]")
+
+    def format(self, record: logging.LogRecord) -> str:
+        original = logging.Formatter.format(self, record)
+        return self._filter(original)
+
+
+def response_content(
+    response: requests.Response,
+    streamed: bool,
+    action: Callable[[bytes], Any] | None,
+    chunk_size: int,
+    *,
+    iterator: bool,
+) -> bytes | Iterator[Any] | None:
+    if iterator:
+        return response.iter_content(chunk_size=chunk_size)
+
     if streamed is False:
         return response.content
 
@@ -31,3 +86,207 @@ def response_content(response, streamed, action, chunk_size):
     for chunk in response.iter_content(chunk_size=chunk_size):
         if chunk:
             action(chunk)
+    return None
+
+
+class Retry:
+    def __init__(
+        self,
+        max_retries: int,
+        obey_rate_limit: bool | None = True,
+        retry_transient_errors: bool | None = False,
+    ) -> None:
+        self.cur_retries = 0
+        self.max_retries = max_retries
+        self.obey_rate_limit = obey_rate_limit
+        self.retry_transient_errors = retry_transient_errors
+
+    def _retryable_status_code(self, status_code: int | None, reason: str = "") -> bool:
+        if status_code == 429 and self.obey_rate_limit:
+            return True
+
+        if not self.retry_transient_errors:
+            return False
+        if status_code in const.RETRYABLE_TRANSIENT_ERROR_CODES:
+            return True
+        if status_code == 409 and "Resource lock" in reason:
+            return True
+
+        return False
+
+    def handle_retry_on_status(
+        self,
+        status_code: int | None,
+        headers: MutableMapping[str, str] | None = None,
+        reason: str = "",
+    ) -> bool:
+        if not self._retryable_status_code(status_code, reason):
+            return False
+
+        if headers is None:
+            headers = {}
+
+        # Response headers documentation:
+        # https://docs.gitlab.com/ee/user/admin_area/settings/user_and_ip_rate_limits.html#response-headers
+        if self.max_retries == -1 or self.cur_retries < self.max_retries:
+            wait_time = 2**self.cur_retries * 0.1
+            if "Retry-After" in headers:
+                wait_time = int(headers["Retry-After"])
+            elif "RateLimit-Reset" in headers:
+                wait_time = int(headers["RateLimit-Reset"]) - time.time()
+            self.cur_retries += 1
+            time.sleep(wait_time)
+            return True
+
+        return False
+
+    def handle_retry(self) -> bool:
+        if self.retry_transient_errors and (
+            self.max_retries == -1 or self.cur_retries < self.max_retries
+        ):
+            wait_time = 2**self.cur_retries * 0.1
+            self.cur_retries += 1
+            time.sleep(wait_time)
+            return True
+
+        return False
+
+
+def _transform_types(
+    data: dict[str, Any],
+    custom_types: dict[str, Any],
+    *,
+    transform_data: bool,
+    transform_files: bool | None = True,
+) -> tuple[dict[str, Any], dict[str, Any]]:
+    """Copy the data dict with attributes that have custom types and transform them
+    before being sent to the server.
+
+    ``transform_files``: If ``True`` (default), also populates the ``files`` dict for
+    FileAttribute types with tuples to prepare fields for requests' MultipartEncoder:
+    https://toolbelt.readthedocs.io/en/latest/user.html#multipart-form-data-encoder
+
+    ``transform_data``: If ``True`` transforms the ``data`` dict with fields
+    suitable for encoding as query parameters for GitLab's API:
+    https://docs.gitlab.com/ee/api/#encoding-api-parameters-of-array-and-hash-types
+
+    Returns:
+        A tuple of the transformed data dict and files dict"""
+
+    # Duplicate data to avoid messing with what the user sent us
+    data = data.copy()
+    if not transform_files and not transform_data:
+        return data, {}
+
+    files = {}
+
+    for attr_name, attr_class in custom_types.items():
+        if attr_name not in data:
+            continue
+
+        gitlab_attribute = attr_class(data[attr_name])
+
+        # if the type is FileAttribute we need to pass the data as file
+        if isinstance(gitlab_attribute, types.FileAttribute) and transform_files:
+            # The GitLab API accepts mixed types
+            # (e.g. a file for avatar image or empty string for removing the avatar)
+            # So if string is empty, keep it in data dict
+            if isinstance(data[attr_name], str) and data[attr_name] == "":
+                continue
+
+            key = gitlab_attribute.get_file_name(attr_name)
+            files[attr_name] = (key, data.pop(attr_name))
+            continue
+
+        if not transform_data:
+            continue
+
+        if isinstance(gitlab_attribute, types.GitlabAttribute):
+            key, value = gitlab_attribute.get_for_api(key=attr_name)
+            if key != attr_name:
+                del data[attr_name]
+            data[key] = value
+
+    return data, files
+
+
+def copy_dict(*, src: dict[str, Any], dest: dict[str, Any]) -> None:
+    for k, v in src.items():
+        if isinstance(v, dict):
+            # NOTE(jlvillal): This provides some support for the `hash` type
+            # https://docs.gitlab.com/ee/api/#hash
+            # Transform dict values to new attributes. For example:
+            # custom_attributes: {'foo', 'bar'} =>
+            #   "custom_attributes['foo']": "bar"
+            for dict_k, dict_v in v.items():
+                dest[f"{k}[{dict_k}]"] = dict_v
+        else:
+            dest[k] = v
+
+
+class EncodedId(str):
+    """A custom `str` class that will return the URL-encoded value of the string.
+
+      * Using it recursively will only url-encode the value once.
+      * Can accept either `str` or `int` as input value.
+      * Can be used in an f-string and output the URL-encoded string.
+
+    Reference to documentation on why this is necessary.
+
+    See::
+
+        https://docs.gitlab.com/ee/api/index.html#namespaced-path-encoding
+        https://docs.gitlab.com/ee/api/index.html#path-parameters
+    """
+
+    def __new__(cls, value: str | int | EncodedId) -> EncodedId:
+        if isinstance(value, EncodedId):
+            return value
+
+        if not isinstance(value, (int, str)):
+            raise TypeError(f"Unsupported type received: {type(value)}")
+        if isinstance(value, str):
+            value = urllib.parse.quote(value, safe="")
+        return super().__new__(cls, value)
+
+
+def remove_none_from_dict(data: dict[str, Any]) -> dict[str, Any]:
+    return {k: v for k, v in data.items() if v is not None}
+
+
+def warn(
+    message: str,
+    *,
+    category: type[Warning] | None = None,
+    source: Any | None = None,
+    show_caller: bool = True,
+) -> None:
+    """This `warnings.warn` wrapper function attempts to show the location causing the
+    warning in the user code that called the library.
+
+    It does this by walking up the stack trace to find the first frame located outside
+    the `gitlab/` directory. This is helpful to users as it shows them their code that
+    is causing the warning.
+    """
+    # Get `stacklevel` for user code so we indicate where issue is in
+    # their code.
+    pg_dir = pathlib.Path(__file__).parent.resolve()
+    stack = traceback.extract_stack()
+    stacklevel = 1
+    warning_from = ""
+    for stacklevel, frame in enumerate(reversed(stack), start=1):
+        warning_from = f" (python-gitlab: {frame.filename}:{frame.lineno})"
+        frame_dir = str(pathlib.Path(frame.filename).parent.resolve())
+        if not frame_dir.startswith(str(pg_dir)):
+            break
+    if show_caller:
+        message += warning_from
+    warnings.warn(
+        message=message, category=category, stacklevel=stacklevel, source=source
+    )
+
+
+@dataclasses.dataclass
+class WarnMessageData:
+    message: str
+    show_caller: bool
diff --git a/gitlab/v4/__init__.py b/gitlab/v4/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/gitlab/v4/cli.py b/gitlab/v4/cli.py
new file mode 100644
index 000000000..87fcaf261
--- /dev/null
+++ b/gitlab/v4/cli.py
@@ -0,0 +1,602 @@
+from __future__ import annotations
+
+import argparse
+import json
+import operator
+import sys
+from typing import Any, TYPE_CHECKING
+
+import gitlab
+import gitlab.base
+import gitlab.v4.objects
+from gitlab import cli
+from gitlab.exceptions import GitlabCiLintError
+
+
+class GitlabCLI:
+    def __init__(
+        self,
+        gl: gitlab.Gitlab,
+        gitlab_resource: str,
+        resource_action: str,
+        args: dict[str, str],
+    ) -> None:
+        self.cls: type[gitlab.base.RESTObject] = cli.gitlab_resource_to_cls(
+            gitlab_resource, namespace=gitlab.v4.objects
+        )
+        self.cls_name = self.cls.__name__
+        self.gitlab_resource = gitlab_resource.replace("-", "_")
+        self.resource_action = resource_action.lower()
+        self.gl = gl
+        self.args = args
+        self.parent_args: dict[str, Any] = {}
+        self.mgr_cls: Any = getattr(gitlab.v4.objects, f"{self.cls.__name__}Manager")
+        # We could do something smart, like splitting the manager name to find
+        # parents, build the chain of managers to get to the final object.
+        # Instead we do something ugly and efficient: interpolate variables in
+        # the class _path attribute, and replace the value with the result.
+
+        self._process_from_parent_attrs()
+
+        self.mgr_cls._path = self.mgr_cls._path.format(**self.parent_args)
+        self.mgr: Any = self.mgr_cls(gl)
+        self.mgr._from_parent_attrs = self.parent_args
+        if self.mgr_cls._types:
+            for attr_name, type_cls in self.mgr_cls._types.items():
+                if attr_name in self.args.keys():
+                    obj = type_cls()
+                    obj.set_from_cli(self.args[attr_name])
+                    self.args[attr_name] = obj.get()
+
+    def _process_from_parent_attrs(self) -> None:
+        """Items in the path need to be url-encoded. There is a 1:1 mapping from
+        mgr_cls._from_parent_attrs <--> mgr_cls._path. Those values must be url-encoded
+        as they may contain a slash '/'."""
+        for key in self.mgr_cls._from_parent_attrs:
+            if key not in self.args:
+                continue
+
+            self.parent_args[key] = gitlab.utils.EncodedId(self.args[key])
+            # If we don't delete it then it will be added to the URL as a query-string
+            del self.args[key]
+
+    def run(self) -> Any:
+        # Check for a method that matches gitlab_resource + action
+        method = f"do_{self.gitlab_resource}_{self.resource_action}"
+        if hasattr(self, method):
+            return getattr(self, method)()
+
+        # Fallback to standard actions (get, list, create, ...)
+        method = f"do_{self.resource_action}"
+        if hasattr(self, method):
+            return getattr(self, method)()
+
+        # Finally try to find custom methods
+        return self.do_custom()
+
+    def do_custom(self) -> Any:
+        class_instance: (
+            gitlab.base.RESTManager[gitlab.base.RESTObject] | gitlab.base.RESTObject
+        )
+        in_obj = cli.custom_actions[self.cls_name][self.resource_action].in_object
+
+        # Get the object (lazy), then act
+        if in_obj:
+            data = {}
+            if self.mgr._from_parent_attrs:
+                for k in self.mgr._from_parent_attrs:
+                    data[k] = self.parent_args[k]
+            if not issubclass(self.cls, gitlab.mixins.GetWithoutIdMixin):
+                if TYPE_CHECKING:
+                    assert isinstance(self.cls._id_attr, str)
+                data[self.cls._id_attr] = self.args.pop(self.cls._id_attr)
+            class_instance = self.cls(self.mgr, data)
+        else:
+            class_instance = self.mgr
+
+        method_name = self.resource_action.replace("-", "_")
+        return getattr(class_instance, method_name)(**self.args)
+
+    def do_project_export_download(self) -> None:
+        try:
+            project = self.gl.projects.get(self.parent_args["project_id"], lazy=True)
+            export_status = project.exports.get()
+            if TYPE_CHECKING:
+                assert export_status is not None
+            data = export_status.download()
+            if TYPE_CHECKING:
+                assert data is not None
+                assert isinstance(data, bytes)
+            sys.stdout.buffer.write(data)
+
+        except Exception as e:  # pragma: no cover, cli.die is unit-tested
+            cli.die("Impossible to download the export", e)
+
+    def do_validate(self) -> None:
+        if TYPE_CHECKING:
+            assert isinstance(self.mgr, gitlab.v4.objects.CiLintManager)
+        try:
+            self.mgr.validate(self.args)
+        except GitlabCiLintError as e:  # pragma: no cover, cli.die is unit-tested
+            cli.die("CI YAML Lint failed", e)
+        except Exception as e:  # pragma: no cover, cli.die is unit-tested
+            cli.die("Cannot validate CI YAML", e)
+
+    def do_create(self) -> gitlab.base.RESTObject:
+        if TYPE_CHECKING:
+            assert isinstance(self.mgr, gitlab.mixins.CreateMixin)
+        try:
+            result = self.mgr.create(self.args)
+            if TYPE_CHECKING:
+                assert isinstance(result, gitlab.base.RESTObject)
+        except Exception as e:  # pragma: no cover, cli.die is unit-tested
+            cli.die("Impossible to create object", e)
+        return result
+
+    def do_list(self) -> list[gitlab.base.RESTObject]:
+        if TYPE_CHECKING:
+            assert isinstance(self.mgr, gitlab.mixins.ListMixin)
+        message_details = gitlab.utils.WarnMessageData(
+            message=(
+                "Your query returned {len_items} of {total_items} items. To return all "
+                "items use `--get-all`. To silence this warning use `--no-get-all`."
+            ),
+            show_caller=False,
+        )
+
+        try:
+            result = self.mgr.list(
+                **self.args, message_details=message_details, iterator=False
+            )
+        except Exception as e:  # pragma: no cover, cli.die is unit-tested
+            cli.die("Impossible to list objects", e)
+        return result
+
+    def do_get(self) -> gitlab.base.RESTObject | None:
+        if isinstance(self.mgr, gitlab.mixins.GetWithoutIdMixin):
+            try:
+                result = self.mgr.get(id=None, **self.args)
+                if TYPE_CHECKING:
+                    assert isinstance(result, gitlab.base.RESTObject) or result is None
+            except Exception as e:  # pragma: no cover, cli.die is unit-tested
+                cli.die("Impossible to get object", e)
+            return result
+
+        if TYPE_CHECKING:
+            assert isinstance(self.mgr, gitlab.mixins.GetMixin)
+            assert isinstance(self.cls._id_attr, str)
+
+        id = self.args.pop(self.cls._id_attr)
+        try:
+            result = self.mgr.get(id, lazy=False, **self.args)
+            if TYPE_CHECKING:
+                assert isinstance(result, gitlab.base.RESTObject) or result is None
+        except Exception as e:  # pragma: no cover, cli.die is unit-tested
+            cli.die("Impossible to get object", e)
+        return result
+
+    def do_delete(self) -> None:
+        if TYPE_CHECKING:
+            assert isinstance(self.mgr, gitlab.mixins.DeleteMixin)
+            assert isinstance(self.cls._id_attr, str)
+        id = self.args.pop(self.cls._id_attr)
+        try:
+            self.mgr.delete(id, **self.args)
+        except Exception as e:  # pragma: no cover, cli.die is unit-tested
+            cli.die("Impossible to destroy object", e)
+
+    def do_update(self) -> dict[str, Any]:
+        if TYPE_CHECKING:
+            assert isinstance(self.mgr, gitlab.mixins.UpdateMixin)
+        if issubclass(self.mgr_cls, gitlab.mixins.GetWithoutIdMixin):
+            id = None
+        else:
+            if TYPE_CHECKING:
+                assert isinstance(self.cls._id_attr, str)
+            id = self.args.pop(self.cls._id_attr)
+
+        try:
+            result = self.mgr.update(id, self.args)
+        except Exception as e:  # pragma: no cover, cli.die is unit-tested
+            cli.die("Impossible to update object", e)
+        return result
+
+
+# https://github.com/python/typeshed/issues/7539#issuecomment-1076581049
+if TYPE_CHECKING:
+    _SubparserType = argparse._SubParsersAction[argparse.ArgumentParser]
+else:
+    _SubparserType = Any
+
+
+def _populate_sub_parser_by_class(
+    cls: type[gitlab.base.RESTObject], sub_parser: _SubparserType
+) -> None:
+    mgr_cls_name = f"{cls.__name__}Manager"
+    mgr_cls = getattr(gitlab.v4.objects, mgr_cls_name)
+
+    action_parsers: dict[str, argparse.ArgumentParser] = {}
+    for action_name, help_text in [
+        ("list", "List the GitLab resources"),
+        ("get", "Get a GitLab resource"),
+        ("create", "Create a GitLab resource"),
+        ("update", "Update a GitLab resource"),
+        ("delete", "Delete a GitLab resource"),
+    ]:
+        if not hasattr(mgr_cls, action_name):
+            continue
+
+        sub_parser_action = sub_parser.add_parser(
+            action_name, conflict_handler="resolve", help=help_text
+        )
+        action_parsers[action_name] = sub_parser_action
+        sub_parser_action.add_argument("--sudo", required=False)
+        if mgr_cls._from_parent_attrs:
+            for x in mgr_cls._from_parent_attrs:
+                sub_parser_action.add_argument(
+                    f"--{x.replace('_', '-')}", required=True
+                )
+
+        if action_name == "list":
+            for x in mgr_cls._list_filters:
+                sub_parser_action.add_argument(
+                    f"--{x.replace('_', '-')}", required=False
+                )
+
+            sub_parser_action.add_argument("--page", required=False, type=int)
+            sub_parser_action.add_argument("--per-page", required=False, type=int)
+            get_all_group = sub_parser_action.add_mutually_exclusive_group()
+            get_all_group.add_argument(
+                "--get-all",
+                required=False,
+                action="store_const",
+                const=True,
+                default=None,
+                dest="get_all",
+                help="Return all items from the server, without pagination.",
+            )
+            get_all_group.add_argument(
+                "--no-get-all",
+                required=False,
+                action="store_const",
+                const=False,
+                default=None,
+                dest="get_all",
+                help="Don't return all items from the server.",
+            )
+
+        if action_name == "delete":
+            if cls._id_attr is not None:
+                id_attr = cls._id_attr.replace("_", "-")
+                sub_parser_action.add_argument(f"--{id_attr}", required=True)
+
+        if action_name == "get":
+            if not issubclass(cls, gitlab.mixins.GetWithoutIdMixin):
+                if cls._id_attr is not None:
+                    id_attr = cls._id_attr.replace("_", "-")
+                    sub_parser_action.add_argument(f"--{id_attr}", required=True)
+
+            for x in mgr_cls._optional_get_attrs:
+                sub_parser_action.add_argument(
+                    f"--{x.replace('_', '-')}", required=False
+                )
+
+        if action_name == "create":
+            for x in mgr_cls._create_attrs.required:
+                sub_parser_action.add_argument(
+                    f"--{x.replace('_', '-')}", required=True
+                )
+            for x in mgr_cls._create_attrs.optional:
+                sub_parser_action.add_argument(
+                    f"--{x.replace('_', '-')}", required=False
+                )
+            if mgr_cls._create_attrs.exclusive:
+                group = sub_parser_action.add_mutually_exclusive_group()
+                for x in mgr_cls._create_attrs.exclusive:
+                    group.add_argument(f"--{x.replace('_', '-')}")
+
+        if action_name == "update":
+            if cls._id_attr is not None:
+                id_attr = cls._id_attr.replace("_", "-")
+                sub_parser_action.add_argument(f"--{id_attr}", required=True)
+
+            for x in mgr_cls._update_attrs.required:
+                if x != cls._id_attr:
+                    sub_parser_action.add_argument(
+                        f"--{x.replace('_', '-')}", required=True
+                    )
+
+            for x in mgr_cls._update_attrs.optional:
+                if x != cls._id_attr:
+                    sub_parser_action.add_argument(
+                        f"--{x.replace('_', '-')}", required=False
+                    )
+
+            if mgr_cls._update_attrs.exclusive:
+                group = sub_parser_action.add_mutually_exclusive_group()
+                for x in mgr_cls._update_attrs.exclusive:
+                    group.add_argument(f"--{x.replace('_', '-')}")
+
+    if cls.__name__ in cli.custom_actions:
+        name = cls.__name__
+        for action_name in cli.custom_actions[name]:
+            custom_action = cli.custom_actions[name][action_name]
+            # NOTE(jlvillal): If we put a function for the `default` value of
+            # the `get` it will always get called, which will break things.
+            action_parser = action_parsers.get(action_name)
+            if action_parser is None:
+                sub_parser_action = sub_parser.add_parser(
+                    action_name, help=custom_action.help
+                )
+            else:
+                sub_parser_action = action_parser
+            # Get the attributes for URL/path construction
+            if mgr_cls._from_parent_attrs:
+                for x in mgr_cls._from_parent_attrs:
+                    sub_parser_action.add_argument(
+                        f"--{x.replace('_', '-')}", required=True
+                    )
+                sub_parser_action.add_argument("--sudo", required=False)
+
+            # We need to get the object somehow
+            if not issubclass(cls, gitlab.mixins.GetWithoutIdMixin):
+                if cls._id_attr is not None and custom_action.requires_id:
+                    id_attr = cls._id_attr.replace("_", "-")
+                    sub_parser_action.add_argument(f"--{id_attr}", required=True)
+
+            for x in custom_action.required:
+                if x != cls._id_attr:
+                    sub_parser_action.add_argument(
+                        f"--{x.replace('_', '-')}", required=True
+                    )
+            for x in custom_action.optional:
+                if x != cls._id_attr:
+                    sub_parser_action.add_argument(
+                        f"--{x.replace('_', '-')}", required=False
+                    )
+
+    if mgr_cls.__name__ in cli.custom_actions:
+        name = mgr_cls.__name__
+        for action_name in cli.custom_actions[name]:
+            # NOTE(jlvillal): If we put a function for the `default` value of
+            # the `get` it will always get called, which will break things.
+            action_parser = action_parsers.get(action_name)
+            if action_parser is None:
+                sub_parser_action = sub_parser.add_parser(action_name)
+            else:
+                sub_parser_action = action_parser
+            if mgr_cls._from_parent_attrs:
+                for x in mgr_cls._from_parent_attrs:
+                    sub_parser_action.add_argument(
+                        f"--{x.replace('_', '-')}", required=True
+                    )
+                sub_parser_action.add_argument("--sudo", required=False)
+
+            custom_action = cli.custom_actions[name][action_name]
+            for x in custom_action.required:
+                if x != cls._id_attr:
+                    sub_parser_action.add_argument(
+                        f"--{x.replace('_', '-')}", required=True
+                    )
+            for x in custom_action.optional:
+                if x != cls._id_attr:
+                    sub_parser_action.add_argument(
+                        f"--{x.replace('_', '-')}", required=False
+                    )
+
+
+def extend_parser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser:
+    subparsers = parser.add_subparsers(
+        title="resource",
+        dest="gitlab_resource",
+        help="The GitLab resource to manipulate.",
+    )
+    subparsers.required = True
+
+    # populate argparse for all Gitlab Object
+    classes: set[type[gitlab.base.RESTObject]] = set()
+    for cls in gitlab.v4.objects.__dict__.values():
+        if not isinstance(cls, type):
+            continue
+        if issubclass(cls, gitlab.base.RESTManager):
+            classes.add(cls._obj_cls)
+
+    for cls in sorted(classes, key=operator.attrgetter("__name__")):
+        if cls is gitlab.base.RESTObject:
+            # Skip managers where _obj_cls is a plain RESTObject class
+            # Those managers do not actually manage any objects and
+            # can only be used to calls specific API paths.
+            continue
+
+        arg_name = cli.cls_to_gitlab_resource(cls)
+        mgr_cls_name = f"{cls.__name__}Manager"
+        mgr_cls = getattr(gitlab.v4.objects, mgr_cls_name)
+        object_group = subparsers.add_parser(
+            arg_name, help=f"API endpoint: {mgr_cls._path}"
+        )
+
+        object_subparsers = object_group.add_subparsers(
+            title="action",
+            dest="resource_action",
+            help="Action to execute on the GitLab resource.",
+        )
+        _populate_sub_parser_by_class(cls, object_subparsers)
+        object_subparsers.required = True
+
+    return parser
+
+
+def get_dict(
+    obj: str | dict[str, Any] | gitlab.base.RESTObject, fields: list[str]
+) -> str | dict[str, Any]:
+    if not isinstance(obj, gitlab.base.RESTObject):
+        return obj
+
+    if fields:
+        return {k: v for k, v in obj.attributes.items() if k in fields}
+    return obj.attributes
+
+
+class JSONPrinter:
+    @staticmethod
+    def display(d: str | dict[str, Any], **_kwargs: Any) -> None:
+        print(json.dumps(d))
+
+    @staticmethod
+    def display_list(
+        data: list[str | dict[str, Any] | gitlab.base.RESTObject],
+        fields: list[str],
+        **_kwargs: Any,
+    ) -> None:
+        print(json.dumps([get_dict(obj, fields) for obj in data]))
+
+
+class YAMLPrinter:
+    @staticmethod
+    def display(d: str | dict[str, Any], **_kwargs: Any) -> None:
+        try:
+            import yaml  # noqa
+
+            print(yaml.safe_dump(d, default_flow_style=False))
+        except ImportError:
+            sys.exit(
+                "PyYaml is not installed.\n"
+                "Install it with `pip install PyYaml` "
+                "to use the yaml output feature"
+            )
+
+    @staticmethod
+    def display_list(
+        data: list[str | dict[str, Any] | gitlab.base.RESTObject],
+        fields: list[str],
+        **_kwargs: Any,
+    ) -> None:
+        try:
+            import yaml  # noqa
+
+            print(
+                yaml.safe_dump(
+                    [get_dict(obj, fields) for obj in data], default_flow_style=False
+                )
+            )
+        except ImportError:
+            sys.exit(
+                "PyYaml is not installed.\n"
+                "Install it with `pip install PyYaml` "
+                "to use the yaml output feature"
+            )
+
+
+class LegacyPrinter:
+    def display(self, _d: str | dict[str, Any], **kwargs: Any) -> None:
+        verbose = kwargs.get("verbose", False)
+        padding = kwargs.get("padding", 0)
+        obj: dict[str, Any] | gitlab.base.RESTObject | None = kwargs.get("obj")
+        if TYPE_CHECKING:
+            assert obj is not None
+
+        def display_dict(d: dict[str, Any], padding: int) -> None:
+            for k in sorted(d.keys()):
+                v = d[k]
+                if isinstance(v, dict):
+                    print(f"{' ' * padding}{k.replace('_', '-')}:")
+                    new_padding = padding + 2
+                    self.display(v, verbose=True, padding=new_padding, obj=v)
+                    continue
+                print(f"{' ' * padding}{k.replace('_', '-')}: {v}")
+
+        if verbose:
+            if isinstance(obj, dict):
+                display_dict(obj, padding)
+                return
+
+            # not a dict, we assume it's a RESTObject
+            if obj._id_attr:
+                id = getattr(obj, obj._id_attr, None)
+                print(f"{obj._id_attr}: {id}")
+            attrs = obj.attributes
+            if obj._id_attr:
+                attrs.pop(obj._id_attr)
+            display_dict(attrs, padding)
+            return
+
+        lines = []
+
+        if TYPE_CHECKING:
+            assert isinstance(obj, gitlab.base.RESTObject)
+
+        if obj._id_attr:
+            id = getattr(obj, obj._id_attr)
+            lines.append(f"{obj._id_attr.replace('_', '-')}: {id}")
+        if obj._repr_attr:
+            value = getattr(obj, obj._repr_attr, "None") or "None"
+            value = value.replace("\r", "").replace("\n", " ")
+            # If the attribute is a note (ProjectCommitComment) then we do
+            # some modifications to fit everything on one line
+            line = f"{obj._repr_attr}: {value}"
+            # ellipsize long lines (comments)
+            if len(line) > 79:
+                line = f"{line[:76]}..."
+            lines.append(line)
+
+        if lines:
+            print("\n".join(lines))
+            return
+
+        print(
+            f"No default fields to show for {obj!r}. "
+            f"Please use  '--verbose' or the JSON/YAML formatters."
+        )
+
+    def display_list(
+        self, data: list[str | gitlab.base.RESTObject], fields: list[str], **kwargs: Any
+    ) -> None:
+        verbose = kwargs.get("verbose", False)
+        for obj in data:
+            if isinstance(obj, gitlab.base.RESTObject):
+                self.display(get_dict(obj, fields), verbose=verbose, obj=obj)
+            else:
+                print(obj)
+            print("")
+
+
+PRINTERS: dict[str, type[JSONPrinter] | type[LegacyPrinter] | type[YAMLPrinter]] = {
+    "json": JSONPrinter,
+    "legacy": LegacyPrinter,
+    "yaml": YAMLPrinter,
+}
+
+
+def run(
+    gl: gitlab.Gitlab,
+    gitlab_resource: str,
+    resource_action: str,
+    args: dict[str, Any],
+    verbose: bool,
+    output: str,
+    fields: list[str],
+) -> None:
+    g_cli = GitlabCLI(
+        gl=gl,
+        gitlab_resource=gitlab_resource,
+        resource_action=resource_action,
+        args=args,
+    )
+    data = g_cli.run()
+
+    printer: JSONPrinter | LegacyPrinter | YAMLPrinter = PRINTERS[output]()
+
+    if isinstance(data, dict):
+        printer.display(data, verbose=True, obj=data)
+    elif isinstance(data, list):
+        printer.display_list(data, fields, verbose=verbose)
+    elif isinstance(data, gitlab.base.RESTObjectList):
+        printer.display_list(list(data), fields, verbose=verbose)
+    elif isinstance(data, gitlab.base.RESTObject):
+        printer.display(get_dict(data, fields), verbose=verbose, obj=data)
+    elif isinstance(data, str):
+        print(data)
+    elif isinstance(data, bytes):
+        sys.stdout.buffer.write(data)
+    elif hasattr(data, "decode"):
+        print(data.decode())
diff --git a/gitlab/v4/objects/__init__.py b/gitlab/v4/objects/__init__.py
new file mode 100644
index 000000000..cc2ffeb52
--- /dev/null
+++ b/gitlab/v4/objects/__init__.py
@@ -0,0 +1,82 @@
+from .access_requests import *
+from .appearance import *
+from .applications import *
+from .artifacts import *
+from .audit_events import *
+from .award_emojis import *
+from .badges import *
+from .boards import *
+from .branches import *
+from .broadcast_messages import *
+from .bulk_imports import *
+from .ci_lint import *
+from .cluster_agents import *
+from .clusters import *
+from .commits import *
+from .container_registry import *
+from .custom_attributes import *
+from .deploy_keys import *
+from .deploy_tokens import *
+from .deployments import *
+from .discussions import *
+from .draft_notes import *
+from .environments import *
+from .epics import *
+from .events import *
+from .export_import import *
+from .features import *
+from .files import *
+from .geo_nodes import *
+from .group_access_tokens import *
+from .groups import *
+from .hooks import *
+from .integrations import *
+from .invitations import *
+from .issues import *
+from .iterations import *
+from .job_token_scope import *
+from .jobs import *
+from .keys import *
+from .labels import *
+from .ldap import *
+from .member_roles import *
+from .members import *
+from .merge_request_approvals import *
+from .merge_requests import *
+from .merge_trains import *
+from .milestones import *
+from .namespaces import *
+from .notes import *
+from .notification_settings import *
+from .package_protection_rules import *
+from .packages import *
+from .pages import *
+from .personal_access_tokens import *
+from .pipelines import *
+from .project_access_tokens import *
+from .projects import *
+from .push_rules import *
+from .registry_protection_repository_rules import *
+from .registry_protection_rules import *
+from .releases import *
+from .repositories import *
+from .resource_groups import *
+from .reviewers import *
+from .runners import *
+from .secure_files import *
+from .service_accounts import *
+from .settings import *
+from .sidekiq import *
+from .snippets import *
+from .statistics import *
+from .status_checks import *
+from .tags import *
+from .templates import *
+from .todos import *
+from .topics import *
+from .triggers import *
+from .users import *
+from .variables import *
+from .wikis import *
+
+__all__ = [name for name in dir() if not name.startswith("_")]
diff --git a/gitlab/v4/objects/access_requests.py b/gitlab/v4/objects/access_requests.py
new file mode 100644
index 000000000..774f4cd25
--- /dev/null
+++ b/gitlab/v4/objects/access_requests.py
@@ -0,0 +1,43 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    AccessRequestMixin,
+    CreateMixin,
+    DeleteMixin,
+    ListMixin,
+    ObjectDeleteMixin,
+)
+
+__all__ = [
+    "GroupAccessRequest",
+    "GroupAccessRequestManager",
+    "ProjectAccessRequest",
+    "ProjectAccessRequestManager",
+]
+
+
+class GroupAccessRequest(AccessRequestMixin, ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class GroupAccessRequestManager(
+    ListMixin[GroupAccessRequest],
+    CreateMixin[GroupAccessRequest],
+    DeleteMixin[GroupAccessRequest],
+):
+    _path = "/groups/{group_id}/access_requests"
+    _obj_cls = GroupAccessRequest
+    _from_parent_attrs = {"group_id": "id"}
+
+
+class ProjectAccessRequest(AccessRequestMixin, ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class ProjectAccessRequestManager(
+    ListMixin[ProjectAccessRequest],
+    CreateMixin[ProjectAccessRequest],
+    DeleteMixin[ProjectAccessRequest],
+):
+    _path = "/projects/{project_id}/access_requests"
+    _obj_cls = ProjectAccessRequest
+    _from_parent_attrs = {"project_id": "id"}
diff --git a/gitlab/v4/objects/appearance.py b/gitlab/v4/objects/appearance.py
new file mode 100644
index 000000000..f59e70d5c
--- /dev/null
+++ b/gitlab/v4/objects/appearance.py
@@ -0,0 +1,61 @@
+from __future__ import annotations
+
+from typing import Any
+
+from gitlab import exceptions as exc
+from gitlab.base import RESTObject
+from gitlab.mixins import GetWithoutIdMixin, SaveMixin, UpdateMixin
+from gitlab.types import RequiredOptional
+
+__all__ = ["ApplicationAppearance", "ApplicationAppearanceManager"]
+
+
+class ApplicationAppearance(SaveMixin, RESTObject):
+    _id_attr = None
+
+
+class ApplicationAppearanceManager(
+    GetWithoutIdMixin[ApplicationAppearance], UpdateMixin[ApplicationAppearance]
+):
+    _path = "/application/appearance"
+    _obj_cls = ApplicationAppearance
+    _update_attrs = RequiredOptional(
+        optional=(
+            "title",
+            "description",
+            "logo",
+            "header_logo",
+            "favicon",
+            "new_project_guidelines",
+            "header_message",
+            "footer_message",
+            "message_background_color",
+            "message_font_color",
+            "email_header_and_footer_enabled",
+        )
+    )
+
+    @exc.on_http_error(exc.GitlabUpdateError)
+    def update(
+        self,
+        id: str | int | None = None,
+        new_data: dict[str, Any] | None = None,
+        **kwargs: Any,
+    ) -> dict[str, Any]:
+        """Update an object on the server.
+
+        Args:
+            id: ID of the object to update (can be None if not required)
+            new_data: the update data for the object
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Returns:
+            The new object data (*not* a RESTObject)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabUpdateError: If the server cannot perform the request
+        """
+        new_data = new_data or {}
+        data = new_data.copy()
+        return super().update(id, data, **kwargs)
diff --git a/gitlab/v4/objects/applications.py b/gitlab/v4/objects/applications.py
new file mode 100644
index 000000000..3394633cf
--- /dev/null
+++ b/gitlab/v4/objects/applications.py
@@ -0,0 +1,20 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import CreateMixin, DeleteMixin, ListMixin, ObjectDeleteMixin
+from gitlab.types import RequiredOptional
+
+__all__ = ["Application", "ApplicationManager"]
+
+
+class Application(ObjectDeleteMixin, RESTObject):
+    _url = "/applications"
+    _repr_attr = "name"
+
+
+class ApplicationManager(
+    ListMixin[Application], CreateMixin[Application], DeleteMixin[Application]
+):
+    _path = "/applications"
+    _obj_cls = Application
+    _create_attrs = RequiredOptional(
+        required=("name", "redirect_uri", "scopes"), optional=("confidential",)
+    )
diff --git a/gitlab/v4/objects/artifacts.py b/gitlab/v4/objects/artifacts.py
new file mode 100644
index 000000000..3aaf3d0f8
--- /dev/null
+++ b/gitlab/v4/objects/artifacts.py
@@ -0,0 +1,230 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/job_artifacts.html
+"""
+
+from __future__ import annotations
+
+from typing import Any, Callable, Iterator, Literal, overload, TYPE_CHECKING
+
+import requests
+
+from gitlab import cli
+from gitlab import exceptions as exc
+from gitlab import utils
+from gitlab.base import RESTManager, RESTObject
+
+__all__ = ["ProjectArtifact", "ProjectArtifactManager"]
+
+
+class ProjectArtifact(RESTObject):
+    """Dummy object to manage custom actions on artifacts"""
+
+    _id_attr = "ref_name"
+
+
+class ProjectArtifactManager(RESTManager[ProjectArtifact]):
+    _obj_cls = ProjectArtifact
+    _path = "/projects/{project_id}/jobs/artifacts"
+    _from_parent_attrs = {"project_id": "id"}
+
+    @exc.on_http_error(exc.GitlabDeleteError)
+    def delete(self, **kwargs: Any) -> None:
+        """Delete the project's artifacts on the server.
+
+        Args:
+            **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
+        """
+        path = self._compute_path("/projects/{project_id}/artifacts")
+
+        if TYPE_CHECKING:
+            assert path is not None
+        self.gitlab.http_delete(path, **kwargs)
+
+    @overload
+    def download(
+        self,
+        ref_name: str,
+        job: str,
+        streamed: Literal[False] = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> bytes: ...
+
+    @overload
+    def download(
+        self,
+        ref_name: str,
+        job: str,
+        streamed: bool = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[True] = True,
+        **kwargs: Any,
+    ) -> Iterator[Any]: ...
+
+    @overload
+    def download(
+        self,
+        ref_name: str,
+        job: str,
+        streamed: Literal[True] = True,
+        action: Callable[[bytes], Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> None: ...
+
+    @cli.register_custom_action(
+        cls_names="ProjectArtifactManager",
+        required=("ref_name", "job"),
+        optional=("job_token",),
+    )
+    @exc.on_http_error(exc.GitlabGetError)
+    def download(
+        self,
+        ref_name: str,
+        job: str,
+        streamed: bool = False,
+        action: Callable[[bytes], Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: bool = False,
+        **kwargs: Any,
+    ) -> bytes | Iterator[Any] | None:
+        """Get the job artifacts archive from a specific tag or branch.
+
+        Args:
+            ref_name: Branch or tag name in repository. HEAD or SHA references
+                are not supported.
+            job: The name of the job.
+            job_token: Job token for multi-project pipeline triggers.
+            streamed: If True the data will be processed by chunks of
+                `chunk_size` and each chunk is passed to `action` for
+                treatment
+            iterator: If True directly return the underlying response
+                iterator
+            action: Callable responsible of dealing with chunk of
+                data
+            chunk_size: 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 artifacts could not be retrieved
+
+        Returns:
+            The artifacts if `streamed` is False, None otherwise.
+        """
+        path = f"{self.path}/{ref_name}/download"
+        result = self.gitlab.http_get(
+            path, job=job, streamed=streamed, raw=True, **kwargs
+        )
+        if TYPE_CHECKING:
+            assert isinstance(result, requests.Response)
+        return utils.response_content(
+            result, streamed, action, chunk_size, iterator=iterator
+        )
+
+    @overload
+    def raw(
+        self,
+        ref_name: str,
+        artifact_path: str,
+        job: str,
+        streamed: Literal[False] = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> bytes: ...
+
+    @overload
+    def raw(
+        self,
+        ref_name: str,
+        artifact_path: str,
+        job: str,
+        streamed: bool = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[True] = True,
+        **kwargs: Any,
+    ) -> Iterator[Any]: ...
+
+    @overload
+    def raw(
+        self,
+        ref_name: str,
+        artifact_path: str,
+        job: str,
+        streamed: Literal[True] = True,
+        action: Callable[[bytes], Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> None: ...
+
+    @cli.register_custom_action(
+        cls_names="ProjectArtifactManager",
+        required=("ref_name", "artifact_path", "job"),
+    )
+    @exc.on_http_error(exc.GitlabGetError)
+    def raw(
+        self,
+        ref_name: str,
+        artifact_path: str,
+        job: str,
+        streamed: bool = False,
+        action: Callable[[bytes], Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: bool = False,
+        **kwargs: Any,
+    ) -> bytes | Iterator[Any] | None:
+        """Download a single artifact file from a specific tag or branch from
+        within the job's artifacts archive.
+
+        Args:
+            ref_name: Branch or tag name in repository. HEAD or SHA references
+                are not supported.
+            artifact_path: Path to a file inside the artifacts archive.
+            job: The name of the job.
+            streamed: If True the data will be processed by chunks of
+                `chunk_size` and each chunk is passed to `action` for
+                treatment
+            iterator: If True directly return the underlying response
+                iterator
+            action: Callable responsible of dealing with chunk of
+                data
+            chunk_size: 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 artifacts could not be retrieved
+
+        Returns:
+            The artifact if `streamed` is False, None otherwise.
+        """
+        path = f"{self.path}/{ref_name}/raw/{artifact_path}"
+        result = self.gitlab.http_get(
+            path, streamed=streamed, raw=True, job=job, **kwargs
+        )
+        if TYPE_CHECKING:
+            assert isinstance(result, requests.Response)
+        return utils.response_content(
+            result, streamed, action, chunk_size, iterator=iterator
+        )
diff --git a/gitlab/v4/objects/audit_events.py b/gitlab/v4/objects/audit_events.py
new file mode 100644
index 000000000..2f4f93f25
--- /dev/null
+++ b/gitlab/v4/objects/audit_events.py
@@ -0,0 +1,58 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/audit_events.html
+"""
+
+from gitlab.base import RESTObject
+from gitlab.mixins import RetrieveMixin
+
+__all__ = [
+    "AuditEvent",
+    "AuditEventManager",
+    "GroupAuditEvent",
+    "GroupAuditEventManager",
+    "ProjectAuditEvent",
+    "ProjectAuditEventManager",
+    "ProjectAudit",
+    "ProjectAuditManager",
+]
+
+
+class AuditEvent(RESTObject):
+    _id_attr = "id"
+
+
+class AuditEventManager(RetrieveMixin[AuditEvent]):
+    _path = "/audit_events"
+    _obj_cls = AuditEvent
+    _list_filters = ("created_after", "created_before", "entity_type", "entity_id")
+
+
+class GroupAuditEvent(RESTObject):
+    _id_attr = "id"
+
+
+class GroupAuditEventManager(RetrieveMixin[GroupAuditEvent]):
+    _path = "/groups/{group_id}/audit_events"
+    _obj_cls = GroupAuditEvent
+    _from_parent_attrs = {"group_id": "id"}
+    _list_filters = ("created_after", "created_before")
+
+
+class ProjectAuditEvent(RESTObject):
+    _id_attr = "id"
+
+
+class ProjectAuditEventManager(RetrieveMixin[ProjectAuditEvent]):
+    _path = "/projects/{project_id}/audit_events"
+    _obj_cls = ProjectAuditEvent
+    _from_parent_attrs = {"project_id": "id"}
+    _list_filters = ("created_after", "created_before")
+
+
+class ProjectAudit(ProjectAuditEvent):
+    pass
+
+
+class ProjectAuditManager(ProjectAuditEventManager):
+    pass
diff --git a/gitlab/v4/objects/award_emojis.py b/gitlab/v4/objects/award_emojis.py
new file mode 100644
index 000000000..4bcc4b2e9
--- /dev/null
+++ b/gitlab/v4/objects/award_emojis.py
@@ -0,0 +1,130 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import NoUpdateMixin, ObjectDeleteMixin
+from gitlab.types import RequiredOptional
+
+__all__ = [
+    "GroupEpicAwardEmoji",
+    "GroupEpicAwardEmojiManager",
+    "GroupEpicNoteAwardEmoji",
+    "GroupEpicNoteAwardEmojiManager",
+    "ProjectIssueAwardEmoji",
+    "ProjectIssueAwardEmojiManager",
+    "ProjectIssueNoteAwardEmoji",
+    "ProjectIssueNoteAwardEmojiManager",
+    "ProjectMergeRequestAwardEmoji",
+    "ProjectMergeRequestAwardEmojiManager",
+    "ProjectMergeRequestNoteAwardEmoji",
+    "ProjectMergeRequestNoteAwardEmojiManager",
+    "ProjectSnippetAwardEmoji",
+    "ProjectSnippetAwardEmojiManager",
+    "ProjectSnippetNoteAwardEmoji",
+    "ProjectSnippetNoteAwardEmojiManager",
+]
+
+
+class GroupEpicAwardEmoji(ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class GroupEpicAwardEmojiManager(NoUpdateMixin[GroupEpicAwardEmoji]):
+    _path = "/groups/{group_id}/epics/{epic_iid}/award_emoji"
+    _obj_cls = GroupEpicAwardEmoji
+    _from_parent_attrs = {"group_id": "group_id", "epic_iid": "iid"}
+    _create_attrs = RequiredOptional(required=("name",))
+
+
+class GroupEpicNoteAwardEmoji(ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class GroupEpicNoteAwardEmojiManager(NoUpdateMixin[GroupEpicNoteAwardEmoji]):
+    _path = "/groups/{group_id}/epics/{epic_iid}/notes/{note_id}/award_emoji"
+    _obj_cls = GroupEpicNoteAwardEmoji
+    _from_parent_attrs = {
+        "group_id": "group_id",
+        "epic_iid": "epic_iid",
+        "note_id": "id",
+    }
+    _create_attrs = RequiredOptional(required=("name",))
+
+
+class ProjectIssueAwardEmoji(ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class ProjectIssueAwardEmojiManager(NoUpdateMixin[ProjectIssueAwardEmoji]):
+    _path = "/projects/{project_id}/issues/{issue_iid}/award_emoji"
+    _obj_cls = ProjectIssueAwardEmoji
+    _from_parent_attrs = {"project_id": "project_id", "issue_iid": "iid"}
+    _create_attrs = RequiredOptional(required=("name",))
+
+
+class ProjectIssueNoteAwardEmoji(ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class ProjectIssueNoteAwardEmojiManager(NoUpdateMixin[ProjectIssueNoteAwardEmoji]):
+    _path = "/projects/{project_id}/issues/{issue_iid}/notes/{note_id}/award_emoji"
+    _obj_cls = ProjectIssueNoteAwardEmoji
+    _from_parent_attrs = {
+        "project_id": "project_id",
+        "issue_iid": "issue_iid",
+        "note_id": "id",
+    }
+    _create_attrs = RequiredOptional(required=("name",))
+
+
+class ProjectMergeRequestAwardEmoji(ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class ProjectMergeRequestAwardEmojiManager(
+    NoUpdateMixin[ProjectMergeRequestAwardEmoji]
+):
+    _path = "/projects/{project_id}/merge_requests/{mr_iid}/award_emoji"
+    _obj_cls = ProjectMergeRequestAwardEmoji
+    _from_parent_attrs = {"project_id": "project_id", "mr_iid": "iid"}
+    _create_attrs = RequiredOptional(required=("name",))
+
+
+class ProjectMergeRequestNoteAwardEmoji(ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class ProjectMergeRequestNoteAwardEmojiManager(
+    NoUpdateMixin[ProjectMergeRequestNoteAwardEmoji]
+):
+    _path = "/projects/{project_id}/merge_requests/{mr_iid}/notes/{note_id}/award_emoji"
+    _obj_cls = ProjectMergeRequestNoteAwardEmoji
+    _from_parent_attrs = {
+        "project_id": "project_id",
+        "mr_iid": "mr_iid",
+        "note_id": "id",
+    }
+    _create_attrs = RequiredOptional(required=("name",))
+
+
+class ProjectSnippetAwardEmoji(ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class ProjectSnippetAwardEmojiManager(NoUpdateMixin[ProjectSnippetAwardEmoji]):
+    _path = "/projects/{project_id}/snippets/{snippet_id}/award_emoji"
+    _obj_cls = ProjectSnippetAwardEmoji
+    _from_parent_attrs = {"project_id": "project_id", "snippet_id": "id"}
+    _create_attrs = RequiredOptional(required=("name",))
+
+
+class ProjectSnippetNoteAwardEmoji(ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class ProjectSnippetNoteAwardEmojiManager(NoUpdateMixin[ProjectSnippetNoteAwardEmoji]):
+    _path = "/projects/{project_id}/snippets/{snippet_id}/notes/{note_id}/award_emoji"
+    _obj_cls = ProjectSnippetNoteAwardEmoji
+    _from_parent_attrs = {
+        "project_id": "project_id",
+        "snippet_id": "snippet_id",
+        "note_id": "id",
+    }
+    _create_attrs = RequiredOptional(required=("name",))
diff --git a/gitlab/v4/objects/badges.py b/gitlab/v4/objects/badges.py
new file mode 100644
index 000000000..8a9ac5b4f
--- /dev/null
+++ b/gitlab/v4/objects/badges.py
@@ -0,0 +1,29 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import BadgeRenderMixin, CRUDMixin, ObjectDeleteMixin, SaveMixin
+from gitlab.types import RequiredOptional
+
+__all__ = ["GroupBadge", "GroupBadgeManager", "ProjectBadge", "ProjectBadgeManager"]
+
+
+class GroupBadge(SaveMixin, ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class GroupBadgeManager(BadgeRenderMixin[GroupBadge], CRUDMixin[GroupBadge]):
+    _path = "/groups/{group_id}/badges"
+    _obj_cls = GroupBadge
+    _from_parent_attrs = {"group_id": "id"}
+    _create_attrs = RequiredOptional(required=("link_url", "image_url"))
+    _update_attrs = RequiredOptional(optional=("link_url", "image_url"))
+
+
+class ProjectBadge(SaveMixin, ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class ProjectBadgeManager(BadgeRenderMixin[ProjectBadge], CRUDMixin[ProjectBadge]):
+    _path = "/projects/{project_id}/badges"
+    _obj_cls = ProjectBadge
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(required=("link_url", "image_url"))
+    _update_attrs = RequiredOptional(optional=("link_url", "image_url"))
diff --git a/gitlab/v4/objects/boards.py b/gitlab/v4/objects/boards.py
new file mode 100644
index 000000000..1683a5fe1
--- /dev/null
+++ b/gitlab/v4/objects/boards.py
@@ -0,0 +1,64 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin
+from gitlab.types import RequiredOptional
+
+__all__ = [
+    "GroupBoardList",
+    "GroupBoardListManager",
+    "GroupBoard",
+    "GroupBoardManager",
+    "ProjectBoardList",
+    "ProjectBoardListManager",
+    "ProjectBoard",
+    "ProjectBoardManager",
+]
+
+
+class GroupBoardList(SaveMixin, ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class GroupBoardListManager(CRUDMixin[GroupBoardList]):
+    _path = "/groups/{group_id}/boards/{board_id}/lists"
+    _obj_cls = GroupBoardList
+    _from_parent_attrs = {"group_id": "group_id", "board_id": "id"}
+    _create_attrs = RequiredOptional(
+        exclusive=("label_id", "assignee_id", "milestone_id", "iteration_id")
+    )
+    _update_attrs = RequiredOptional(required=("position",))
+
+
+class GroupBoard(SaveMixin, ObjectDeleteMixin, RESTObject):
+    lists: GroupBoardListManager
+
+
+class GroupBoardManager(CRUDMixin[GroupBoard]):
+    _path = "/groups/{group_id}/boards"
+    _obj_cls = GroupBoard
+    _from_parent_attrs = {"group_id": "id"}
+    _create_attrs = RequiredOptional(required=("name",))
+
+
+class ProjectBoardList(SaveMixin, ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class ProjectBoardListManager(CRUDMixin[ProjectBoardList]):
+    _path = "/projects/{project_id}/boards/{board_id}/lists"
+    _obj_cls = ProjectBoardList
+    _from_parent_attrs = {"project_id": "project_id", "board_id": "id"}
+    _create_attrs = RequiredOptional(
+        exclusive=("label_id", "assignee_id", "milestone_id", "iteration_id")
+    )
+    _update_attrs = RequiredOptional(required=("position",))
+
+
+class ProjectBoard(SaveMixin, ObjectDeleteMixin, RESTObject):
+    lists: ProjectBoardListManager
+
+
+class ProjectBoardManager(CRUDMixin[ProjectBoard]):
+    _path = "/projects/{project_id}/boards"
+    _obj_cls = ProjectBoard
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(required=("name",))
diff --git a/gitlab/v4/objects/branches.py b/gitlab/v4/objects/branches.py
new file mode 100644
index 000000000..12dbf8848
--- /dev/null
+++ b/gitlab/v4/objects/branches.py
@@ -0,0 +1,75 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    CRUDMixin,
+    NoUpdateMixin,
+    ObjectDeleteMixin,
+    SaveMixin,
+    UpdateMethod,
+)
+from gitlab.types import RequiredOptional
+
+__all__ = [
+    "ProjectBranch",
+    "ProjectBranchManager",
+    "ProjectProtectedBranch",
+    "ProjectProtectedBranchManager",
+]
+
+
+class ProjectBranch(ObjectDeleteMixin, RESTObject):
+    _id_attr = "name"
+
+
+class ProjectBranchManager(NoUpdateMixin[ProjectBranch]):
+    _path = "/projects/{project_id}/repository/branches"
+    _obj_cls = ProjectBranch
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(required=("branch", "ref"))
+
+
+class ProjectProtectedBranch(SaveMixin, ObjectDeleteMixin, RESTObject):
+    _id_attr = "name"
+
+
+class ProjectProtectedBranchManager(CRUDMixin[ProjectProtectedBranch]):
+    _path = "/projects/{project_id}/protected_branches"
+    _obj_cls = ProjectProtectedBranch
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("name",),
+        optional=(
+            "push_access_level",
+            "merge_access_level",
+            "unprotect_access_level",
+            "allow_force_push",
+            "allowed_to_push",
+            "allowed_to_merge",
+            "allowed_to_unprotect",
+            "code_owner_approval_required",
+        ),
+    )
+    _update_method = UpdateMethod.PATCH
+
+
+class GroupProtectedBranch(SaveMixin, ObjectDeleteMixin, RESTObject):
+    _id_attr = "name"
+
+
+class GroupProtectedBranchManager(CRUDMixin[GroupProtectedBranch]):
+    _path = "/groups/{group_id}/protected_branches"
+    _obj_cls = GroupProtectedBranch
+    _from_parent_attrs = {"group_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("name",),
+        optional=(
+            "push_access_level",
+            "merge_access_level",
+            "unprotect_access_level",
+            "allow_force_push",
+            "allowed_to_push",
+            "allowed_to_merge",
+            "allowed_to_unprotect",
+            "code_owner_approval_required",
+        ),
+    )
+    _update_method = UpdateMethod.PATCH
diff --git a/gitlab/v4/objects/broadcast_messages.py b/gitlab/v4/objects/broadcast_messages.py
new file mode 100644
index 000000000..08ea080ac
--- /dev/null
+++ b/gitlab/v4/objects/broadcast_messages.py
@@ -0,0 +1,30 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin
+from gitlab.types import ArrayAttribute, RequiredOptional
+
+__all__ = ["BroadcastMessage", "BroadcastMessageManager"]
+
+
+class BroadcastMessage(SaveMixin, ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class BroadcastMessageManager(CRUDMixin[BroadcastMessage]):
+    _path = "/broadcast_messages"
+    _obj_cls = BroadcastMessage
+
+    _create_attrs = RequiredOptional(
+        required=("message",),
+        optional=("starts_at", "ends_at", "color", "font", "target_access_levels"),
+    )
+    _update_attrs = RequiredOptional(
+        optional=(
+            "message",
+            "starts_at",
+            "ends_at",
+            "color",
+            "font",
+            "target_access_levels",
+        )
+    )
+    _types = {"target_access_levels": ArrayAttribute}
diff --git a/gitlab/v4/objects/bulk_imports.py b/gitlab/v4/objects/bulk_imports.py
new file mode 100644
index 000000000..b171618a5
--- /dev/null
+++ b/gitlab/v4/objects/bulk_imports.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+from gitlab.base import RESTObject
+from gitlab.mixins import CreateMixin, ListMixin, RefreshMixin, RetrieveMixin
+from gitlab.types import RequiredOptional
+
+__all__ = [
+    "BulkImport",
+    "BulkImportManager",
+    "BulkImportAllEntity",
+    "BulkImportAllEntityManager",
+    "BulkImportEntity",
+    "BulkImportEntityManager",
+]
+
+
+class BulkImport(RefreshMixin, RESTObject):
+    entities: BulkImportEntityManager
+
+
+class BulkImportManager(CreateMixin[BulkImport], RetrieveMixin[BulkImport]):
+    _path = "/bulk_imports"
+    _obj_cls = BulkImport
+    _create_attrs = RequiredOptional(required=("configuration", "entities"))
+    _list_filters = ("sort", "status")
+
+
+class BulkImportEntity(RefreshMixin, RESTObject):
+    pass
+
+
+class BulkImportEntityManager(RetrieveMixin[BulkImportEntity]):
+    _path = "/bulk_imports/{bulk_import_id}/entities"
+    _obj_cls = BulkImportEntity
+    _from_parent_attrs = {"bulk_import_id": "id"}
+    _list_filters = ("sort", "status")
+
+
+class BulkImportAllEntity(RESTObject):
+    pass
+
+
+class BulkImportAllEntityManager(ListMixin[BulkImportAllEntity]):
+    _path = "/bulk_imports/entities"
+    _obj_cls = BulkImportAllEntity
+    _list_filters = ("sort", "status")
diff --git a/gitlab/v4/objects/ci_lint.py b/gitlab/v4/objects/ci_lint.py
new file mode 100644
index 000000000..01d38373d
--- /dev/null
+++ b/gitlab/v4/objects/ci_lint.py
@@ -0,0 +1,72 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/lint.html
+"""
+
+from typing import Any
+
+from gitlab.base import RESTObject
+from gitlab.cli import register_custom_action
+from gitlab.exceptions import GitlabCiLintError
+from gitlab.mixins import CreateMixin, GetWithoutIdMixin
+from gitlab.types import RequiredOptional
+
+__all__ = ["CiLint", "CiLintManager", "ProjectCiLint", "ProjectCiLintManager"]
+
+
+class CiLint(RESTObject):
+    _id_attr = None
+
+
+class CiLintManager(CreateMixin[CiLint]):
+    _path = "/ci/lint"
+    _obj_cls = CiLint
+    _create_attrs = RequiredOptional(
+        required=("content",), optional=("include_merged_yaml", "include_jobs")
+    )
+
+    @register_custom_action(
+        cls_names="CiLintManager",
+        required=("content",),
+        optional=("include_merged_yaml", "include_jobs"),
+    )
+    def validate(self, *args: Any, **kwargs: Any) -> None:
+        """Raise an error if the CI Lint results are not valid.
+
+        This is a custom python-gitlab method to wrap lint endpoints."""
+        result = self.create(*args, **kwargs)
+
+        if result.status != "valid":
+            message = ",\n".join(result.errors)
+            raise GitlabCiLintError(message)
+
+
+class ProjectCiLint(RESTObject):
+    _id_attr = None
+
+
+class ProjectCiLintManager(
+    GetWithoutIdMixin[ProjectCiLint], CreateMixin[ProjectCiLint]
+):
+    _path = "/projects/{project_id}/ci/lint"
+    _obj_cls = ProjectCiLint
+    _from_parent_attrs = {"project_id": "id"}
+    _optional_get_attrs = ("dry_run", "include_jobs", "ref")
+    _create_attrs = RequiredOptional(
+        required=("content",), optional=("dry_run", "include_jobs", "ref")
+    )
+
+    @register_custom_action(
+        cls_names="ProjectCiLintManager",
+        required=("content",),
+        optional=("dry_run", "include_jobs", "ref"),
+    )
+    def validate(self, *args: Any, **kwargs: Any) -> None:
+        """Raise an error if the Project CI Lint results are not valid.
+
+        This is a custom python-gitlab method to wrap lint endpoints."""
+        result = self.create(*args, **kwargs)
+
+        if not result.valid:
+            message = ",\n".join(result.errors)
+            raise GitlabCiLintError(message)
diff --git a/gitlab/v4/objects/cluster_agents.py b/gitlab/v4/objects/cluster_agents.py
new file mode 100644
index 000000000..082945d63
--- /dev/null
+++ b/gitlab/v4/objects/cluster_agents.py
@@ -0,0 +1,16 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import NoUpdateMixin, ObjectDeleteMixin, SaveMixin
+from gitlab.types import RequiredOptional
+
+__all__ = ["ProjectClusterAgent", "ProjectClusterAgentManager"]
+
+
+class ProjectClusterAgent(SaveMixin, ObjectDeleteMixin, RESTObject):
+    _repr_attr = "name"
+
+
+class ProjectClusterAgentManager(NoUpdateMixin[ProjectClusterAgent]):
+    _path = "/projects/{project_id}/cluster_agents"
+    _obj_cls = ProjectClusterAgent
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(required=("name",))
diff --git a/gitlab/v4/objects/clusters.py b/gitlab/v4/objects/clusters.py
new file mode 100644
index 000000000..8b8cb5599
--- /dev/null
+++ b/gitlab/v4/objects/clusters.py
@@ -0,0 +1,105 @@
+from __future__ import annotations
+
+from typing import Any
+
+from gitlab import exceptions as exc
+from gitlab.base import RESTObject
+from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin
+from gitlab.types import RequiredOptional
+
+__all__ = [
+    "GroupCluster",
+    "GroupClusterManager",
+    "ProjectCluster",
+    "ProjectClusterManager",
+]
+
+
+class GroupCluster(SaveMixin, ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class GroupClusterManager(CRUDMixin[GroupCluster]):
+    _path = "/groups/{group_id}/clusters"
+    _obj_cls = GroupCluster
+    _from_parent_attrs = {"group_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("name", "platform_kubernetes_attributes"),
+        optional=("domain", "enabled", "managed", "environment_scope"),
+    )
+    _update_attrs = RequiredOptional(
+        optional=(
+            "name",
+            "domain",
+            "management_project_id",
+            "platform_kubernetes_attributes",
+            "environment_scope",
+        )
+    )
+
+    @exc.on_http_error(exc.GitlabStopError)
+    def create(self, data: dict[str, Any] | None = None, **kwargs: Any) -> GroupCluster:
+        """Create a new object.
+
+        Args:
+            data: Parameters to send to the server to create the
+                         resource
+            **kwargs: Extra options to send to the server (e.g. sudo or
+                      'ref_name', 'stage', 'name', 'all')
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabCreateError: If the server cannot perform the request
+
+        Returns:
+            A new instance of the manage object class build with
+                the data sent by the server
+        """
+        path = f"{self.path}/user"
+        return super().create(data, path=path, **kwargs)
+
+
+class ProjectCluster(SaveMixin, ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class ProjectClusterManager(CRUDMixin[ProjectCluster]):
+    _path = "/projects/{project_id}/clusters"
+    _obj_cls = ProjectCluster
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("name", "platform_kubernetes_attributes"),
+        optional=("domain", "enabled", "managed", "environment_scope"),
+    )
+    _update_attrs = RequiredOptional(
+        optional=(
+            "name",
+            "domain",
+            "management_project_id",
+            "platform_kubernetes_attributes",
+            "environment_scope",
+        )
+    )
+
+    @exc.on_http_error(exc.GitlabStopError)
+    def create(
+        self, data: dict[str, Any] | None = None, **kwargs: Any
+    ) -> ProjectCluster:
+        """Create a new object.
+
+        Args:
+            data: Parameters to send to the server to create the
+                         resource
+            **kwargs: Extra options to send to the server (e.g. sudo or
+                      'ref_name', 'stage', 'name', 'all')
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabCreateError: If the server cannot perform the request
+
+        Returns:
+            A new instance of the manage object class build with
+                the data sent by the server
+        """
+        path = f"{self.path}/user"
+        return super().create(data, path=path, **kwargs)
diff --git a/gitlab/v4/objects/commits.py b/gitlab/v4/objects/commits.py
new file mode 100644
index 000000000..54402e278
--- /dev/null
+++ b/gitlab/v4/objects/commits.py
@@ -0,0 +1,253 @@
+from __future__ import annotations
+
+from typing import Any, TYPE_CHECKING
+
+import requests
+
+import gitlab
+from gitlab import cli
+from gitlab import exceptions as exc
+from gitlab.base import RESTObject
+from gitlab.mixins import CreateMixin, ListMixin, RefreshMixin, RetrieveMixin
+from gitlab.types import RequiredOptional
+
+from .discussions import ProjectCommitDiscussionManager  # noqa: F401
+
+__all__ = [
+    "ProjectCommit",
+    "ProjectCommitManager",
+    "ProjectCommitComment",
+    "ProjectCommitCommentManager",
+    "ProjectCommitStatus",
+    "ProjectCommitStatusManager",
+]
+
+
+class ProjectCommit(RESTObject):
+    _repr_attr = "title"
+
+    comments: ProjectCommitCommentManager
+    discussions: ProjectCommitDiscussionManager
+    statuses: ProjectCommitStatusManager
+
+    @cli.register_custom_action(cls_names="ProjectCommit")
+    @exc.on_http_error(exc.GitlabGetError)
+    def diff(self, **kwargs: Any) -> gitlab.GitlabList | list[dict[str, Any]]:
+        """Generate the commit diff.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabGetError: If the diff could not be retrieved
+
+        Returns:
+            The changes done in this commit
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/diff"
+        return self.manager.gitlab.http_list(path, **kwargs)
+
+    @cli.register_custom_action(cls_names="ProjectCommit", required=("branch",))
+    @exc.on_http_error(exc.GitlabCherryPickError)
+    def cherry_pick(
+        self, branch: str, **kwargs: Any
+    ) -> dict[str, Any] | requests.Response:
+        """Cherry-pick a commit into a branch.
+
+        Args:
+            branch: Name of target branch
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabCherryPickError: If the cherry-pick could not be performed
+
+        Returns:
+            The new commit data (*not* a RESTObject)
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/cherry_pick"
+        post_data = {"branch": branch}
+        return self.manager.gitlab.http_post(path, post_data=post_data, **kwargs)
+
+    @cli.register_custom_action(cls_names="ProjectCommit", optional=("type",))
+    @exc.on_http_error(exc.GitlabGetError)
+    def refs(
+        self, type: str = "all", **kwargs: Any
+    ) -> gitlab.GitlabList | list[dict[str, Any]]:
+        """List the references the commit is pushed to.
+
+        Args:
+            type: The scope of references ('branch', 'tag' or 'all')
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabGetError: If the references could not be retrieved
+
+        Returns:
+            The references the commit is pushed to.
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/refs"
+        query_data = {"type": type}
+        return self.manager.gitlab.http_list(path, query_data=query_data, **kwargs)
+
+    @cli.register_custom_action(cls_names="ProjectCommit")
+    @exc.on_http_error(exc.GitlabGetError)
+    def merge_requests(self, **kwargs: Any) -> gitlab.GitlabList | list[dict[str, Any]]:
+        """List the merge requests related to the commit.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabGetError: If the references could not be retrieved
+
+        Returns:
+            The merge requests related to the commit.
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/merge_requests"
+        return self.manager.gitlab.http_list(path, **kwargs)
+
+    @cli.register_custom_action(cls_names="ProjectCommit", required=("branch",))
+    @exc.on_http_error(exc.GitlabRevertError)
+    def revert(self, branch: str, **kwargs: Any) -> dict[str, Any] | requests.Response:
+        """Revert a commit on a given branch.
+
+        Args:
+            branch: 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:
+            The new commit data (*not* a RESTObject)
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/revert"
+        post_data = {"branch": branch}
+        return self.manager.gitlab.http_post(path, post_data=post_data, **kwargs)
+
+    @cli.register_custom_action(cls_names="ProjectCommit")
+    @exc.on_http_error(exc.GitlabGetError)
+    def sequence(self, **kwargs: Any) -> dict[str, Any] | requests.Response:
+        """Get the sequence number of the commit.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabGetError: If the sequence number could not be retrieved
+
+        Returns:
+            The commit's sequence number
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/sequence"
+        return self.manager.gitlab.http_get(path, **kwargs)
+
+    @cli.register_custom_action(cls_names="ProjectCommit")
+    @exc.on_http_error(exc.GitlabGetError)
+    def signature(self, **kwargs: Any) -> dict[str, Any] | requests.Response:
+        """Get the signature of the commit.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabGetError: If the signature could not be retrieved
+
+        Returns:
+            The commit's signature data
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/signature"
+        return self.manager.gitlab.http_get(path, **kwargs)
+
+
+class ProjectCommitManager(RetrieveMixin[ProjectCommit], CreateMixin[ProjectCommit]):
+    _path = "/projects/{project_id}/repository/commits"
+    _obj_cls = ProjectCommit
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("branch", "commit_message", "actions"),
+        optional=("author_email", "author_name"),
+    )
+    _list_filters = (
+        "all",
+        "ref_name",
+        "since",
+        "until",
+        "path",
+        "with_stats",
+        "first_parent",
+        "order",
+        "trailers",
+    )
+
+
+class ProjectCommitComment(RESTObject):
+    _id_attr = None
+    _repr_attr = "note"
+
+
+class ProjectCommitCommentManager(
+    ListMixin[ProjectCommitComment], CreateMixin[ProjectCommitComment]
+):
+    _path = "/projects/{project_id}/repository/commits/{commit_id}/comments"
+    _obj_cls = ProjectCommitComment
+    _from_parent_attrs = {"project_id": "project_id", "commit_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("note",), optional=("path", "line", "line_type")
+    )
+
+
+class ProjectCommitStatus(RefreshMixin, RESTObject):
+    pass
+
+
+class ProjectCommitStatusManager(
+    ListMixin[ProjectCommitStatus], CreateMixin[ProjectCommitStatus]
+):
+    _path = "/projects/{project_id}/repository/commits/{commit_id}/statuses"
+    _obj_cls = ProjectCommitStatus
+    _from_parent_attrs = {"project_id": "project_id", "commit_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("state",),
+        optional=("description", "name", "context", "ref", "target_url", "coverage"),
+    )
+
+    @exc.on_http_error(exc.GitlabCreateError)
+    def create(
+        self, data: dict[str, Any] | None = None, **kwargs: Any
+    ) -> ProjectCommitStatus:
+        """Create a new object.
+
+        Args:
+            data: Parameters to send to the server to create the
+                         resource
+            **kwargs: Extra options to send to the server (e.g. sudo or
+                      'ref_name', 'stage', 'name', 'all')
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabCreateError: If the server cannot perform the request
+
+        Returns:
+            A new instance of the manage object class build with
+                the data sent by the server
+        """
+        # project_id and commit_id are in the data dict when using the CLI, but
+        # they are missing when using only the API
+        # See #511
+        base_path = "/projects/{project_id}/statuses/{commit_id}"
+        path: str | None
+        if data is not None and "project_id" in data and "commit_id" in data:
+            path = base_path.format(**data)
+        else:
+            path = self._compute_path(base_path)
+        if TYPE_CHECKING:
+            assert path is not None
+        return super().create(data, path=path, **kwargs)
diff --git a/gitlab/v4/objects/container_registry.py b/gitlab/v4/objects/container_registry.py
new file mode 100644
index 000000000..c8165126b
--- /dev/null
+++ b/gitlab/v4/objects/container_registry.py
@@ -0,0 +1,90 @@
+from __future__ import annotations
+
+from typing import Any
+
+from gitlab import cli
+from gitlab import exceptions as exc
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    DeleteMixin,
+    GetMixin,
+    ListMixin,
+    ObjectDeleteMixin,
+    RetrieveMixin,
+)
+
+__all__ = [
+    "GroupRegistryRepositoryManager",
+    "ProjectRegistryRepository",
+    "ProjectRegistryRepositoryManager",
+    "ProjectRegistryTag",
+    "ProjectRegistryTagManager",
+    "RegistryRepository",
+    "RegistryRepositoryManager",
+]
+
+
+class ProjectRegistryRepository(ObjectDeleteMixin, RESTObject):
+    tags: ProjectRegistryTagManager
+
+
+class ProjectRegistryRepositoryManager(
+    DeleteMixin[ProjectRegistryRepository], ListMixin[ProjectRegistryRepository]
+):
+    _path = "/projects/{project_id}/registry/repositories"
+    _obj_cls = ProjectRegistryRepository
+    _from_parent_attrs = {"project_id": "id"}
+
+
+class ProjectRegistryTag(ObjectDeleteMixin, RESTObject):
+    _id_attr = "name"
+
+
+class ProjectRegistryTagManager(
+    DeleteMixin[ProjectRegistryTag], RetrieveMixin[ProjectRegistryTag]
+):
+    _obj_cls = ProjectRegistryTag
+    _from_parent_attrs = {"project_id": "project_id", "repository_id": "id"}
+    _path = "/projects/{project_id}/registry/repositories/{repository_id}/tags"
+
+    @cli.register_custom_action(
+        cls_names="ProjectRegistryTagManager",
+        required=("name_regex_delete",),
+        optional=("keep_n", "name_regex_keep", "older_than"),
+    )
+    @exc.on_http_error(exc.GitlabDeleteError)
+    def delete_in_bulk(self, name_regex_delete: str, **kwargs: Any) -> None:
+        """Delete Tag in bulk
+
+        Args:
+            name_regex_delete: The regex of the name to delete. To delete all
+                tags specify .*.
+            keep_n: The amount of latest tags of given name to keep.
+            name_regex_keep: The regex of the name to keep. This value
+                overrides any matches from name_regex.
+            older_than: Tags to delete that are older than the given time,
+                written in human readable form 1h, 1d, 1month.
+            **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
+        """
+        valid_attrs = ["keep_n", "name_regex_keep", "older_than"]
+        data = {"name_regex_delete": name_regex_delete}
+        data.update({k: v for k, v in kwargs.items() if k in valid_attrs})
+        self.gitlab.http_delete(self.path, query_data=data, **kwargs)
+
+
+class GroupRegistryRepositoryManager(ListMixin[ProjectRegistryRepository]):
+    _path = "/groups/{group_id}/registry/repositories"
+    _obj_cls = ProjectRegistryRepository
+    _from_parent_attrs = {"group_id": "id"}
+
+
+class RegistryRepository(RESTObject):
+    _repr_attr = "path"
+
+
+class RegistryRepositoryManager(GetMixin[RegistryRepository]):
+    _path = "/registry/repositories"
+    _obj_cls = RegistryRepository
diff --git a/gitlab/v4/objects/custom_attributes.py b/gitlab/v4/objects/custom_attributes.py
new file mode 100644
index 000000000..94b2c1722
--- /dev/null
+++ b/gitlab/v4/objects/custom_attributes.py
@@ -0,0 +1,53 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import DeleteMixin, ObjectDeleteMixin, RetrieveMixin, SetMixin
+
+__all__ = [
+    "GroupCustomAttribute",
+    "GroupCustomAttributeManager",
+    "ProjectCustomAttribute",
+    "ProjectCustomAttributeManager",
+    "UserCustomAttribute",
+    "UserCustomAttributeManager",
+]
+
+
+class GroupCustomAttribute(ObjectDeleteMixin, RESTObject):
+    _id_attr = "key"
+
+
+class GroupCustomAttributeManager(
+    RetrieveMixin[GroupCustomAttribute],
+    SetMixin[GroupCustomAttribute],
+    DeleteMixin[GroupCustomAttribute],
+):
+    _path = "/groups/{group_id}/custom_attributes"
+    _obj_cls = GroupCustomAttribute
+    _from_parent_attrs = {"group_id": "id"}
+
+
+class ProjectCustomAttribute(ObjectDeleteMixin, RESTObject):
+    _id_attr = "key"
+
+
+class ProjectCustomAttributeManager(
+    RetrieveMixin[ProjectCustomAttribute],
+    SetMixin[ProjectCustomAttribute],
+    DeleteMixin[ProjectCustomAttribute],
+):
+    _path = "/projects/{project_id}/custom_attributes"
+    _obj_cls = ProjectCustomAttribute
+    _from_parent_attrs = {"project_id": "id"}
+
+
+class UserCustomAttribute(ObjectDeleteMixin, RESTObject):
+    _id_attr = "key"
+
+
+class UserCustomAttributeManager(
+    RetrieveMixin[UserCustomAttribute],
+    SetMixin[UserCustomAttribute],
+    DeleteMixin[UserCustomAttribute],
+):
+    _path = "/users/{user_id}/custom_attributes"
+    _obj_cls = UserCustomAttribute
+    _from_parent_attrs = {"user_id": "id"}
diff --git a/gitlab/v4/objects/deploy_keys.py b/gitlab/v4/objects/deploy_keys.py
new file mode 100644
index 000000000..a592933a8
--- /dev/null
+++ b/gitlab/v4/objects/deploy_keys.py
@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+from typing import Any
+
+import requests
+
+from gitlab import cli
+from gitlab import exceptions as exc
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    CreateMixin,
+    CRUDMixin,
+    ListMixin,
+    ObjectDeleteMixin,
+    SaveMixin,
+)
+from gitlab.types import RequiredOptional
+
+__all__ = ["DeployKey", "DeployKeyManager", "ProjectKey", "ProjectKeyManager"]
+
+
+class DeployKey(RESTObject):
+    pass
+
+
+class DeployKeyManager(CreateMixin[DeployKey], ListMixin[DeployKey]):
+    _path = "/deploy_keys"
+    _obj_cls = DeployKey
+    _create_attrs = RequiredOptional(
+        required=("title", "key"), optional=("expires_at",)
+    )
+
+
+class ProjectKey(SaveMixin, ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class ProjectKeyManager(CRUDMixin[ProjectKey]):
+    _path = "/projects/{project_id}/deploy_keys"
+    _obj_cls = ProjectKey
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("title", "key"), optional=("can_push", "expires_at")
+    )
+    _update_attrs = RequiredOptional(optional=("title", "can_push", "expires_at"))
+
+    @cli.register_custom_action(
+        cls_names="ProjectKeyManager",
+        required=("key_id",),
+        requires_id=False,
+        help="Enable a deploy key for the project",
+    )
+    @exc.on_http_error(exc.GitlabProjectDeployKeyError)
+    def enable(self, key_id: int, **kwargs: Any) -> dict[str, Any] | requests.Response:
+        """Enable a deploy key for a project.
+
+        Args:
+            key_id: The ID of the key to enable
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabProjectDeployKeyError: If the key could not be enabled
+
+        Returns:
+            A dict of the result.
+        """
+        path = f"{self.path}/{key_id}/enable"
+        return self.gitlab.http_post(path, **kwargs)
diff --git a/gitlab/v4/objects/deploy_tokens.py b/gitlab/v4/objects/deploy_tokens.py
new file mode 100644
index 000000000..16136f259
--- /dev/null
+++ b/gitlab/v4/objects/deploy_tokens.py
@@ -0,0 +1,66 @@
+from gitlab import types
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    CreateMixin,
+    DeleteMixin,
+    ListMixin,
+    ObjectDeleteMixin,
+    RetrieveMixin,
+)
+from gitlab.types import RequiredOptional
+
+__all__ = [
+    "DeployToken",
+    "DeployTokenManager",
+    "GroupDeployToken",
+    "GroupDeployTokenManager",
+    "ProjectDeployToken",
+    "ProjectDeployTokenManager",
+]
+
+
+class DeployToken(ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class DeployTokenManager(ListMixin[DeployToken]):
+    _path = "/deploy_tokens"
+    _obj_cls = DeployToken
+
+
+class GroupDeployToken(ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class GroupDeployTokenManager(
+    RetrieveMixin[GroupDeployToken],
+    CreateMixin[GroupDeployToken],
+    DeleteMixin[GroupDeployToken],
+):
+    _path = "/groups/{group_id}/deploy_tokens"
+    _from_parent_attrs = {"group_id": "id"}
+    _obj_cls = GroupDeployToken
+    _create_attrs = RequiredOptional(
+        required=("name", "scopes"), optional=("expires_at", "username")
+    )
+    _list_filters = ("scopes",)
+    _types = {"scopes": types.ArrayAttribute}
+
+
+class ProjectDeployToken(ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class ProjectDeployTokenManager(
+    RetrieveMixin[ProjectDeployToken],
+    CreateMixin[ProjectDeployToken],
+    DeleteMixin[ProjectDeployToken],
+):
+    _path = "/projects/{project_id}/deploy_tokens"
+    _from_parent_attrs = {"project_id": "id"}
+    _obj_cls = ProjectDeployToken
+    _create_attrs = RequiredOptional(
+        required=("name", "scopes"), optional=("expires_at", "username")
+    )
+    _list_filters = ("scopes",)
+    _types = {"scopes": types.ArrayAttribute}
diff --git a/gitlab/v4/objects/deployments.py b/gitlab/v4/objects/deployments.py
new file mode 100644
index 000000000..b7a186ca2
--- /dev/null
+++ b/gitlab/v4/objects/deployments.py
@@ -0,0 +1,87 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/deployments.html
+"""
+
+from __future__ import annotations
+
+from typing import Any, TYPE_CHECKING
+
+from gitlab import cli
+from gitlab import exceptions as exc
+from gitlab.base import RESTObject
+from gitlab.mixins import CreateMixin, RetrieveMixin, SaveMixin, UpdateMixin
+from gitlab.types import RequiredOptional
+
+from .merge_requests import ProjectDeploymentMergeRequestManager  # noqa: F401
+
+__all__ = ["ProjectDeployment", "ProjectDeploymentManager"]
+
+
+class ProjectDeployment(SaveMixin, RESTObject):
+    mergerequests: ProjectDeploymentMergeRequestManager
+
+    @cli.register_custom_action(
+        cls_names="ProjectDeployment",
+        required=("status",),
+        optional=("comment", "represented_as"),
+    )
+    @exc.on_http_error(exc.GitlabDeploymentApprovalError)
+    def approval(
+        self,
+        status: str,
+        comment: str | None = None,
+        represented_as: str | None = None,
+        **kwargs: Any,
+    ) -> dict[str, Any]:
+        """Approve or reject a blocked deployment.
+
+        Args:
+            status: Either "approved" or "rejected"
+            comment: A comment to go with the approval
+            represented_as: The name of the User/Group/Role to use for the
+                            approval, when the user belongs to multiple
+                            approval rules.
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabMRApprovalError: If the approval failed
+
+        Returns:
+           A dict containing the result.
+
+        https://docs.gitlab.com/ee/api/deployments.html#approve-or-reject-a-blocked-deployment
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/approval"
+        data = {"status": status}
+        if comment is not None:
+            data["comment"] = comment
+        if represented_as is not None:
+            data["represented_as"] = represented_as
+
+        server_data = self.manager.gitlab.http_post(path, post_data=data, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+        return server_data
+
+
+class ProjectDeploymentManager(
+    RetrieveMixin[ProjectDeployment],
+    CreateMixin[ProjectDeployment],
+    UpdateMixin[ProjectDeployment],
+):
+    _path = "/projects/{project_id}/deployments"
+    _obj_cls = ProjectDeployment
+    _from_parent_attrs = {"project_id": "id"}
+    _list_filters = (
+        "order_by",
+        "sort",
+        "updated_after",
+        "updated_before",
+        "environment",
+        "status",
+    )
+    _create_attrs = RequiredOptional(
+        required=("sha", "ref", "tag", "status", "environment")
+    )
diff --git a/gitlab/v4/objects/discussions.py b/gitlab/v4/objects/discussions.py
new file mode 100644
index 000000000..c43898b5e
--- /dev/null
+++ b/gitlab/v4/objects/discussions.py
@@ -0,0 +1,78 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import CreateMixin, RetrieveMixin, SaveMixin, UpdateMixin
+from gitlab.types import RequiredOptional
+
+from .notes import (  # noqa: F401
+    ProjectCommitDiscussionNoteManager,
+    ProjectIssueDiscussionNoteManager,
+    ProjectMergeRequestDiscussionNoteManager,
+    ProjectSnippetDiscussionNoteManager,
+)
+
+__all__ = [
+    "ProjectCommitDiscussion",
+    "ProjectCommitDiscussionManager",
+    "ProjectIssueDiscussion",
+    "ProjectIssueDiscussionManager",
+    "ProjectMergeRequestDiscussion",
+    "ProjectMergeRequestDiscussionManager",
+    "ProjectSnippetDiscussion",
+    "ProjectSnippetDiscussionManager",
+]
+
+
+class ProjectCommitDiscussion(RESTObject):
+    notes: ProjectCommitDiscussionNoteManager
+
+
+class ProjectCommitDiscussionManager(
+    RetrieveMixin[ProjectCommitDiscussion], CreateMixin[ProjectCommitDiscussion]
+):
+    _path = "/projects/{project_id}/repository/commits/{commit_id}/discussions"
+    _obj_cls = ProjectCommitDiscussion
+    _from_parent_attrs = {"project_id": "project_id", "commit_id": "id"}
+    _create_attrs = RequiredOptional(required=("body",), optional=("created_at",))
+
+
+class ProjectIssueDiscussion(RESTObject):
+    notes: ProjectIssueDiscussionNoteManager
+
+
+class ProjectIssueDiscussionManager(
+    RetrieveMixin[ProjectIssueDiscussion], CreateMixin[ProjectIssueDiscussion]
+):
+    _path = "/projects/{project_id}/issues/{issue_iid}/discussions"
+    _obj_cls = ProjectIssueDiscussion
+    _from_parent_attrs = {"project_id": "project_id", "issue_iid": "iid"}
+    _create_attrs = RequiredOptional(required=("body",), optional=("created_at",))
+
+
+class ProjectMergeRequestDiscussion(SaveMixin, RESTObject):
+    notes: ProjectMergeRequestDiscussionNoteManager
+
+
+class ProjectMergeRequestDiscussionManager(
+    RetrieveMixin[ProjectMergeRequestDiscussion],
+    CreateMixin[ProjectMergeRequestDiscussion],
+    UpdateMixin[ProjectMergeRequestDiscussion],
+):
+    _path = "/projects/{project_id}/merge_requests/{mr_iid}/discussions"
+    _obj_cls = ProjectMergeRequestDiscussion
+    _from_parent_attrs = {"project_id": "project_id", "mr_iid": "iid"}
+    _create_attrs = RequiredOptional(
+        required=("body",), optional=("created_at", "position")
+    )
+    _update_attrs = RequiredOptional(required=("resolved",))
+
+
+class ProjectSnippetDiscussion(RESTObject):
+    notes: ProjectSnippetDiscussionNoteManager
+
+
+class ProjectSnippetDiscussionManager(
+    RetrieveMixin[ProjectSnippetDiscussion], CreateMixin[ProjectSnippetDiscussion]
+):
+    _path = "/projects/{project_id}/snippets/{snippet_id}/discussions"
+    _obj_cls = ProjectSnippetDiscussion
+    _from_parent_attrs = {"project_id": "project_id", "snippet_id": "id"}
+    _create_attrs = RequiredOptional(required=("body",), optional=("created_at",))
diff --git a/gitlab/v4/objects/draft_notes.py b/gitlab/v4/objects/draft_notes.py
new file mode 100644
index 000000000..68b8d4b2d
--- /dev/null
+++ b/gitlab/v4/objects/draft_notes.py
@@ -0,0 +1,33 @@
+from typing import Any
+
+from gitlab.base import RESTObject
+from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin
+from gitlab.types import RequiredOptional
+
+__all__ = ["ProjectMergeRequestDraftNote", "ProjectMergeRequestDraftNoteManager"]
+
+
+class ProjectMergeRequestDraftNote(ObjectDeleteMixin, SaveMixin, RESTObject):
+    def publish(self, **kwargs: Any) -> None:
+        path = f"{self.manager.path}/{self.encoded_id}/publish"
+        self.manager.gitlab.http_put(path, **kwargs)
+
+
+class ProjectMergeRequestDraftNoteManager(CRUDMixin[ProjectMergeRequestDraftNote]):
+    _path = "/projects/{project_id}/merge_requests/{mr_iid}/draft_notes"
+    _obj_cls = ProjectMergeRequestDraftNote
+    _from_parent_attrs = {"project_id": "project_id", "mr_iid": "iid"}
+    _create_attrs = RequiredOptional(
+        required=("note",),
+        optional=(
+            "commit_id",
+            "in_reply_to_discussion_id",
+            "position",
+            "resolve_discussion",
+        ),
+    )
+    _update_attrs = RequiredOptional(optional=("position",))
+
+    def bulk_publish(self, **kwargs: Any) -> None:
+        path = f"{self.path}/bulk_publish"
+        self.gitlab.http_post(path, **kwargs)
diff --git a/gitlab/v4/objects/environments.py b/gitlab/v4/objects/environments.py
new file mode 100644
index 000000000..5d2c55108
--- /dev/null
+++ b/gitlab/v4/objects/environments.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+from typing import Any
+
+import requests
+
+from gitlab import cli
+from gitlab import exceptions as exc
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    CreateMixin,
+    DeleteMixin,
+    ObjectDeleteMixin,
+    RetrieveMixin,
+    SaveMixin,
+    UpdateMixin,
+)
+from gitlab.types import ArrayAttribute, RequiredOptional
+
+__all__ = [
+    "ProjectEnvironment",
+    "ProjectEnvironmentManager",
+    "ProjectProtectedEnvironment",
+    "ProjectProtectedEnvironmentManager",
+]
+
+
+class ProjectEnvironment(SaveMixin, ObjectDeleteMixin, RESTObject):
+    @cli.register_custom_action(cls_names="ProjectEnvironment")
+    @exc.on_http_error(exc.GitlabStopError)
+    def stop(self, **kwargs: Any) -> dict[str, Any] | requests.Response:
+        """Stop the environment.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabStopError: If the operation failed
+
+        Returns:
+           A dict of the result.
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/stop"
+        return self.manager.gitlab.http_post(path, **kwargs)
+
+
+class ProjectEnvironmentManager(
+    RetrieveMixin[ProjectEnvironment],
+    CreateMixin[ProjectEnvironment],
+    UpdateMixin[ProjectEnvironment],
+    DeleteMixin[ProjectEnvironment],
+):
+    _path = "/projects/{project_id}/environments"
+    _obj_cls = ProjectEnvironment
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(required=("name",), optional=("external_url",))
+    _update_attrs = RequiredOptional(optional=("name", "external_url"))
+    _list_filters = ("name", "search", "states")
+
+
+class ProjectProtectedEnvironment(ObjectDeleteMixin, RESTObject):
+    _id_attr = "name"
+    _repr_attr = "name"
+
+
+class ProjectProtectedEnvironmentManager(
+    RetrieveMixin[ProjectProtectedEnvironment],
+    CreateMixin[ProjectProtectedEnvironment],
+    DeleteMixin[ProjectProtectedEnvironment],
+):
+    _path = "/projects/{project_id}/protected_environments"
+    _obj_cls = ProjectProtectedEnvironment
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("name", "deploy_access_levels"),
+        optional=("required_approval_count", "approval_rules"),
+    )
+    _types = {"deploy_access_levels": ArrayAttribute, "approval_rules": ArrayAttribute}
diff --git a/gitlab/v4/objects/epics.py b/gitlab/v4/objects/epics.py
new file mode 100644
index 000000000..06400528f
--- /dev/null
+++ b/gitlab/v4/objects/epics.py
@@ -0,0 +1,118 @@
+from __future__ import annotations
+
+from typing import Any, TYPE_CHECKING
+
+from gitlab import exceptions as exc
+from gitlab import types
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    CreateMixin,
+    CRUDMixin,
+    DeleteMixin,
+    ListMixin,
+    ObjectDeleteMixin,
+    SaveMixin,
+    UpdateMixin,
+)
+from gitlab.types import RequiredOptional
+
+from .events import GroupEpicResourceLabelEventManager  # noqa: F401
+from .notes import GroupEpicNoteManager  # noqa: F401
+
+__all__ = ["GroupEpic", "GroupEpicManager", "GroupEpicIssue", "GroupEpicIssueManager"]
+
+
+class GroupEpic(ObjectDeleteMixin, SaveMixin, RESTObject):
+    _id_attr = "iid"
+
+    issues: GroupEpicIssueManager
+    resourcelabelevents: GroupEpicResourceLabelEventManager
+    notes: GroupEpicNoteManager
+
+
+class GroupEpicManager(CRUDMixin[GroupEpic]):
+    _path = "/groups/{group_id}/epics"
+    _obj_cls = GroupEpic
+    _from_parent_attrs = {"group_id": "id"}
+    _list_filters = ("author_id", "labels", "order_by", "sort", "search")
+    _create_attrs = RequiredOptional(
+        required=("title",),
+        optional=("labels", "description", "start_date", "end_date"),
+    )
+    _update_attrs = RequiredOptional(
+        optional=("title", "labels", "description", "start_date", "end_date")
+    )
+    _types = {"labels": types.CommaSeparatedListAttribute}
+
+
+class GroupEpicIssue(ObjectDeleteMixin, SaveMixin, RESTObject):
+    _id_attr = "epic_issue_id"
+    # Define type for 'manager' here So mypy won't complain about
+    # 'self.manager.update()' call in the 'save' method.
+    manager: GroupEpicIssueManager
+
+    def save(self, **kwargs: Any) -> None:
+        """Save 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)
+
+        Raise:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabUpdateError: If the server cannot perform the request
+        """
+        updated_data = self._get_updated_data()
+        # Nothing to update. Server fails if sent an empty dict.
+        if not updated_data:
+            return
+
+        # call the manager
+        obj_id = self.encoded_id
+        self.manager.update(obj_id, updated_data, **kwargs)
+
+
+class GroupEpicIssueManager(
+    ListMixin[GroupEpicIssue],
+    CreateMixin[GroupEpicIssue],
+    UpdateMixin[GroupEpicIssue],
+    DeleteMixin[GroupEpicIssue],
+):
+    _path = "/groups/{group_id}/epics/{epic_iid}/issues"
+    _obj_cls = GroupEpicIssue
+    _from_parent_attrs = {"group_id": "group_id", "epic_iid": "iid"}
+    _create_attrs = RequiredOptional(required=("issue_id",))
+    _update_attrs = RequiredOptional(optional=("move_before_id", "move_after_id"))
+
+    @exc.on_http_error(exc.GitlabCreateError)
+    def create(
+        self, data: dict[str, Any] | None = None, **kwargs: Any
+    ) -> GroupEpicIssue:
+        """Create a new object.
+
+        Args:
+            data: Parameters to send to the server to create the
+                         resource
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabCreateError: If the server cannot perform the request
+
+        Returns:
+            A new instance of the manage object class build with
+                the data sent by the server
+        """
+        if TYPE_CHECKING:
+            assert data is not None
+        self._create_attrs.validate_attrs(data=data)
+        path = f"{self.path}/{data.pop('issue_id')}"
+        server_data = self.gitlab.http_post(path, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+        # The epic_issue_id attribute doesn't exist when creating the resource,
+        # but is used everywhere elese. Let's create it to be consistent client
+        # side
+        server_data["epic_issue_id"] = server_data["id"]
+        return self._obj_cls(self, server_data)
diff --git a/gitlab/v4/objects/events.py b/gitlab/v4/objects/events.py
new file mode 100644
index 000000000..c9594ce34
--- /dev/null
+++ b/gitlab/v4/objects/events.py
@@ -0,0 +1,166 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import ListMixin, RetrieveMixin
+
+__all__ = [
+    "Event",
+    "EventManager",
+    "GroupEpicResourceLabelEvent",
+    "GroupEpicResourceLabelEventManager",
+    "ProjectEvent",
+    "ProjectEventManager",
+    "ProjectIssueResourceLabelEvent",
+    "ProjectIssueResourceLabelEventManager",
+    "ProjectIssueResourceMilestoneEvent",
+    "ProjectIssueResourceMilestoneEventManager",
+    "ProjectIssueResourceStateEvent",
+    "ProjectIssueResourceIterationEventManager",
+    "ProjectIssueResourceWeightEventManager",
+    "ProjectIssueResourceIterationEvent",
+    "ProjectIssueResourceWeightEvent",
+    "ProjectIssueResourceStateEventManager",
+    "ProjectMergeRequestResourceLabelEvent",
+    "ProjectMergeRequestResourceLabelEventManager",
+    "ProjectMergeRequestResourceMilestoneEvent",
+    "ProjectMergeRequestResourceMilestoneEventManager",
+    "ProjectMergeRequestResourceStateEvent",
+    "ProjectMergeRequestResourceStateEventManager",
+    "UserEvent",
+    "UserEventManager",
+]
+
+
+class Event(RESTObject):
+    _id_attr = None
+    _repr_attr = "target_title"
+
+
+class EventManager(ListMixin[Event]):
+    _path = "/events"
+    _obj_cls = Event
+    _list_filters = ("action", "target_type", "before", "after", "sort", "scope")
+
+
+class GroupEpicResourceLabelEvent(RESTObject):
+    pass
+
+
+class GroupEpicResourceLabelEventManager(RetrieveMixin[GroupEpicResourceLabelEvent]):
+    _path = "/groups/{group_id}/epics/{epic_id}/resource_label_events"
+    _obj_cls = GroupEpicResourceLabelEvent
+    _from_parent_attrs = {"group_id": "group_id", "epic_id": "id"}
+
+
+class ProjectEvent(Event):
+    pass
+
+
+class ProjectEventManager(EventManager):
+    _path = "/projects/{project_id}/events"
+    _obj_cls = ProjectEvent
+    _from_parent_attrs = {"project_id": "id"}
+
+
+class ProjectIssueResourceLabelEvent(RESTObject):
+    pass
+
+
+class ProjectIssueResourceLabelEventManager(
+    RetrieveMixin[ProjectIssueResourceLabelEvent]
+):
+    _path = "/projects/{project_id}/issues/{issue_iid}/resource_label_events"
+    _obj_cls = ProjectIssueResourceLabelEvent
+    _from_parent_attrs = {"project_id": "project_id", "issue_iid": "iid"}
+
+
+class ProjectIssueResourceMilestoneEvent(RESTObject):
+    pass
+
+
+class ProjectIssueResourceMilestoneEventManager(
+    RetrieveMixin[ProjectIssueResourceMilestoneEvent]
+):
+    _path = "/projects/{project_id}/issues/{issue_iid}/resource_milestone_events"
+    _obj_cls = ProjectIssueResourceMilestoneEvent
+    _from_parent_attrs = {"project_id": "project_id", "issue_iid": "iid"}
+
+
+class ProjectIssueResourceStateEvent(RESTObject):
+    pass
+
+
+class ProjectIssueResourceStateEventManager(
+    RetrieveMixin[ProjectIssueResourceStateEvent]
+):
+    _path = "/projects/{project_id}/issues/{issue_iid}/resource_state_events"
+    _obj_cls = ProjectIssueResourceStateEvent
+    _from_parent_attrs = {"project_id": "project_id", "issue_iid": "iid"}
+
+
+class ProjectIssueResourceIterationEvent(RESTObject):
+    pass
+
+
+class ProjectIssueResourceIterationEventManager(
+    RetrieveMixin[ProjectIssueResourceIterationEvent]
+):
+    _path = "/projects/{project_id}/issues/{issue_iid}/resource_iteration_events"
+    _obj_cls = ProjectIssueResourceIterationEvent
+    _from_parent_attrs = {"project_id": "project_id", "issue_iid": "iid"}
+
+
+class ProjectIssueResourceWeightEvent(RESTObject):
+    pass
+
+
+class ProjectIssueResourceWeightEventManager(
+    RetrieveMixin[ProjectIssueResourceWeightEvent]
+):
+    _path = "/projects/{project_id}/issues/{issue_iid}/resource_weight_events"
+    _obj_cls = ProjectIssueResourceWeightEvent
+    _from_parent_attrs = {"project_id": "project_id", "issue_iid": "iid"}
+
+
+class ProjectMergeRequestResourceLabelEvent(RESTObject):
+    pass
+
+
+class ProjectMergeRequestResourceLabelEventManager(
+    RetrieveMixin[ProjectMergeRequestResourceLabelEvent]
+):
+    _path = "/projects/{project_id}/merge_requests/{mr_iid}/resource_label_events"
+    _obj_cls = ProjectMergeRequestResourceLabelEvent
+    _from_parent_attrs = {"project_id": "project_id", "mr_iid": "iid"}
+
+
+class ProjectMergeRequestResourceMilestoneEvent(RESTObject):
+    pass
+
+
+class ProjectMergeRequestResourceMilestoneEventManager(
+    RetrieveMixin[ProjectMergeRequestResourceMilestoneEvent]
+):
+    _path = "/projects/{project_id}/merge_requests/{mr_iid}/resource_milestone_events"
+    _obj_cls = ProjectMergeRequestResourceMilestoneEvent
+    _from_parent_attrs = {"project_id": "project_id", "mr_iid": "iid"}
+
+
+class ProjectMergeRequestResourceStateEvent(RESTObject):
+    pass
+
+
+class ProjectMergeRequestResourceStateEventManager(
+    RetrieveMixin[ProjectMergeRequestResourceStateEvent]
+):
+    _path = "/projects/{project_id}/merge_requests/{mr_iid}/resource_state_events"
+    _obj_cls = ProjectMergeRequestResourceStateEvent
+    _from_parent_attrs = {"project_id": "project_id", "mr_iid": "iid"}
+
+
+class UserEvent(Event):
+    pass
+
+
+class UserEventManager(EventManager):
+    _path = "/users/{user_id}/events"
+    _obj_cls = UserEvent
+    _from_parent_attrs = {"user_id": "id"}
diff --git a/gitlab/v4/objects/export_import.py b/gitlab/v4/objects/export_import.py
new file mode 100644
index 000000000..fba2bc867
--- /dev/null
+++ b/gitlab/v4/objects/export_import.py
@@ -0,0 +1,57 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import CreateMixin, DownloadMixin, GetWithoutIdMixin, RefreshMixin
+from gitlab.types import RequiredOptional
+
+__all__ = [
+    "GroupExport",
+    "GroupExportManager",
+    "GroupImport",
+    "GroupImportManager",
+    "ProjectExport",
+    "ProjectExportManager",
+    "ProjectImport",
+    "ProjectImportManager",
+]
+
+
+class GroupExport(DownloadMixin, RESTObject):
+    _id_attr = None
+
+
+class GroupExportManager(GetWithoutIdMixin[GroupExport], CreateMixin[GroupExport]):
+    _path = "/groups/{group_id}/export"
+    _obj_cls = GroupExport
+    _from_parent_attrs = {"group_id": "id"}
+
+
+class GroupImport(RESTObject):
+    _id_attr = None
+
+
+class GroupImportManager(GetWithoutIdMixin[GroupImport]):
+    _path = "/groups/{group_id}/import"
+    _obj_cls = GroupImport
+    _from_parent_attrs = {"group_id": "id"}
+
+
+class ProjectExport(DownloadMixin, RefreshMixin, RESTObject):
+    _id_attr = None
+
+
+class ProjectExportManager(
+    GetWithoutIdMixin[ProjectExport], CreateMixin[ProjectExport]
+):
+    _path = "/projects/{project_id}/export"
+    _obj_cls = ProjectExport
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(optional=("description",))
+
+
+class ProjectImport(RefreshMixin, RESTObject):
+    _id_attr = None
+
+
+class ProjectImportManager(GetWithoutIdMixin[ProjectImport]):
+    _path = "/projects/{project_id}/import"
+    _obj_cls = ProjectImport
+    _from_parent_attrs = {"project_id": "id"}
diff --git a/gitlab/v4/objects/features.py b/gitlab/v4/objects/features.py
new file mode 100644
index 000000000..8bc48a697
--- /dev/null
+++ b/gitlab/v4/objects/features.py
@@ -0,0 +1,68 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/features.html
+"""
+
+from __future__ import annotations
+
+from typing import Any, TYPE_CHECKING
+
+from gitlab import exceptions as exc
+from gitlab import utils
+from gitlab.base import RESTObject
+from gitlab.mixins import DeleteMixin, ListMixin, ObjectDeleteMixin
+
+__all__ = ["Feature", "FeatureManager"]
+
+
+class Feature(ObjectDeleteMixin, RESTObject):
+    _id_attr = "name"
+
+
+class FeatureManager(ListMixin[Feature], DeleteMixin[Feature]):
+    _path = "/features/"
+    _obj_cls = Feature
+
+    @exc.on_http_error(exc.GitlabSetError)
+    def set(
+        self,
+        name: str,
+        value: bool | int,
+        feature_group: str | None = None,
+        user: str | None = None,
+        group: str | None = None,
+        project: str | None = None,
+        **kwargs: Any,
+    ) -> Feature:
+        """Create or update the object.
+
+        Args:
+            name: The value to set for the object
+            value: The value to set for the object
+            feature_group: A feature group name
+            user: A GitLab username
+            group: A GitLab group
+            project: A GitLab project in form group/project
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabSetError: If an error occurred
+
+        Returns:
+            The created/updated attribute
+        """
+        name = utils.EncodedId(name)
+        path = f"{self.path}/{name}"
+        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)
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+        return self._obj_cls(self, server_data)
diff --git a/gitlab/v4/objects/files.py b/gitlab/v4/objects/files.py
new file mode 100644
index 000000000..757d16eeb
--- /dev/null
+++ b/gitlab/v4/objects/files.py
@@ -0,0 +1,382 @@
+from __future__ import annotations
+
+import base64
+from typing import Any, Callable, Iterator, Literal, overload, TYPE_CHECKING
+
+import requests
+
+from gitlab import cli
+from gitlab import exceptions as exc
+from gitlab import utils
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    CreateMixin,
+    DeleteMixin,
+    ObjectDeleteMixin,
+    SaveMixin,
+    UpdateMixin,
+)
+from gitlab.types import RequiredOptional
+
+__all__ = ["ProjectFile", "ProjectFileManager"]
+
+
+class ProjectFile(SaveMixin, ObjectDeleteMixin, RESTObject):
+    _id_attr = "file_path"
+    _repr_attr = "file_path"
+    branch: str
+    commit_message: str
+    file_path: str
+    manager: ProjectFileManager
+    content: str  # since the `decode()` method uses `self.content`
+
+    def decode(self) -> bytes:
+        """Returns the decoded content of the file.
+
+        Returns:
+            The decoded content.
+        """
+        return base64.b64decode(self.content)
+
+    # NOTE(jlvillal): Signature doesn't match SaveMixin.save() so ignore
+    # type error
+    def save(  # type: ignore[override]
+        self, branch: str, commit_message: str, **kwargs: Any
+    ) -> None:
+        """Save the changes made to the file to the server.
+
+        The object is updated to match what the server returns.
+
+        Args:
+            branch: Branch in which the file will be updated
+            commit_message: Message to send with the commit
+            **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
+        """
+        self.branch = branch
+        self.commit_message = commit_message
+        self.file_path = utils.EncodedId(self.file_path)
+        super().save(**kwargs)
+
+    @exc.on_http_error(exc.GitlabDeleteError)
+    # NOTE(jlvillal): Signature doesn't match DeleteMixin.delete() so ignore
+    # type error
+    def delete(  # type: ignore[override]
+        self, branch: str, commit_message: str, **kwargs: Any
+    ) -> None:
+        """Delete the file from the server.
+
+        Args:
+            branch: Branch from which the file will be removed
+            commit_message: 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
+        """
+        file_path = self.encoded_id
+        if TYPE_CHECKING:
+            assert isinstance(file_path, str)
+        self.manager.delete(file_path, branch, commit_message, **kwargs)
+
+
+class ProjectFileManager(
+    CreateMixin[ProjectFile], UpdateMixin[ProjectFile], DeleteMixin[ProjectFile]
+):
+    _path = "/projects/{project_id}/repository/files"
+    _obj_cls = ProjectFile
+    _from_parent_attrs = {"project_id": "id"}
+    _optional_get_attrs: tuple[str, ...] = ()
+    _create_attrs = RequiredOptional(
+        required=("file_path", "branch", "content", "commit_message"),
+        optional=(
+            "encoding",
+            "author_email",
+            "author_name",
+            "execute_filemode",
+            "start_branch",
+        ),
+    )
+    _update_attrs = RequiredOptional(
+        required=("file_path", "branch", "content", "commit_message"),
+        optional=(
+            "encoding",
+            "author_email",
+            "author_name",
+            "execute_filemode",
+            "start_branch",
+            "last_commit_id",
+        ),
+    )
+
+    @cli.register_custom_action(
+        cls_names="ProjectFileManager", required=("file_path", "ref")
+    )
+    @exc.on_http_error(exc.GitlabGetError)
+    def get(self, file_path: str, ref: str, **kwargs: Any) -> ProjectFile:
+        """Retrieve a single file.
+
+        Args:
+            file_path: Path of the file to retrieve
+            ref: Name of the branch, tag or commit
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabGetError: If the file could not be retrieved
+
+        Returns:
+            The generated RESTObject
+        """
+        if TYPE_CHECKING:
+            assert file_path is not None
+        file_path = utils.EncodedId(file_path)
+        path = f"{self.path}/{file_path}"
+        server_data = self.gitlab.http_get(path, ref=ref, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+        return self._obj_cls(self, server_data)
+
+    @exc.on_http_error(exc.GitlabHeadError)
+    def head(
+        self, file_path: str, ref: str, **kwargs: Any
+    ) -> requests.structures.CaseInsensitiveDict[Any]:
+        """Retrieve just metadata for a single file.
+
+        Args:
+            file_path: Path of the file to retrieve
+            ref: Name of the branch, tag or commit
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabGetError: If the file could not be retrieved
+
+        Returns:
+            The response headers as a dictionary
+        """
+        if TYPE_CHECKING:
+            assert file_path is not None
+        file_path = utils.EncodedId(file_path)
+        path = f"{self.path}/{file_path}"
+        return self.gitlab.http_head(path, ref=ref, **kwargs)
+
+    @cli.register_custom_action(
+        cls_names="ProjectFileManager",
+        required=("file_path", "branch", "content", "commit_message"),
+        optional=(
+            "encoding",
+            "author_email",
+            "author_name",
+            "execute_filemode",
+            "start_branch",
+        ),
+    )
+    @exc.on_http_error(exc.GitlabCreateError)
+    def create(self, data: dict[str, Any] | None = None, **kwargs: Any) -> ProjectFile:
+        """Create a new object.
+
+        Args:
+            data: parameters to send to the server to create the
+                         resource
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Returns:
+            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
+        """
+
+        if TYPE_CHECKING:
+            assert data is not None
+        self._create_attrs.validate_attrs(data=data)
+        new_data = data.copy()
+        file_path = utils.EncodedId(new_data.pop("file_path"))
+        path = f"{self.path}/{file_path}"
+        server_data = self.gitlab.http_post(path, post_data=new_data, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+        return self._obj_cls(self, server_data)
+
+    @exc.on_http_error(exc.GitlabUpdateError)
+    # NOTE(jlvillal): Signature doesn't match UpdateMixin.update() so ignore
+    # type error
+    def update(  # type: ignore[override]
+        self, file_path: str, new_data: dict[str, Any] | None = None, **kwargs: Any
+    ) -> dict[str, Any]:
+        """Update an object on the server.
+
+        Args:
+            id: ID of the object to update (can be None if not required)
+            new_data: the update data for the object
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Returns:
+            The new object data (*not* a RESTObject)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabUpdateError: If the server cannot perform the request
+        """
+        new_data = new_data or {}
+        data = new_data.copy()
+        file_path = utils.EncodedId(file_path)
+        data["file_path"] = file_path
+        path = f"{self.path}/{file_path}"
+        self._update_attrs.validate_attrs(data=data)
+        result = self.gitlab.http_put(path, post_data=data, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(result, dict)
+        return result
+
+    @cli.register_custom_action(
+        cls_names="ProjectFileManager",
+        required=("file_path", "branch", "commit_message"),
+    )
+    @exc.on_http_error(exc.GitlabDeleteError)
+    # NOTE(jlvillal): Signature doesn't match DeleteMixin.delete() so ignore
+    # type error
+    def delete(  # type: ignore[override]
+        self, file_path: str, branch: str, commit_message: str, **kwargs: Any
+    ) -> None:
+        """Delete a file on the server.
+
+        Args:
+            file_path: Path of the file to remove
+            branch: Branch from which the file will be removed
+            commit_message: 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
+        """
+        file_path = utils.EncodedId(file_path)
+        path = f"{self.path}/{file_path}"
+        data = {"branch": branch, "commit_message": commit_message}
+        self.gitlab.http_delete(path, query_data=data, **kwargs)
+
+    @overload
+    def raw(
+        self,
+        file_path: str,
+        ref: str | None = None,
+        streamed: Literal[False] = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> bytes: ...
+
+    @overload
+    def raw(
+        self,
+        file_path: str,
+        ref: str | None = None,
+        streamed: bool = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[True] = True,
+        **kwargs: Any,
+    ) -> Iterator[Any]: ...
+
+    @overload
+    def raw(
+        self,
+        file_path: str,
+        ref: str | None = None,
+        streamed: Literal[True] = True,
+        action: Callable[[bytes], Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> None: ...
+
+    @cli.register_custom_action(
+        cls_names="ProjectFileManager", required=("file_path",), optional=("ref",)
+    )
+    @exc.on_http_error(exc.GitlabGetError)
+    def raw(
+        self,
+        file_path: str,
+        ref: str | None = None,
+        streamed: bool = False,
+        action: Callable[..., Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: bool = False,
+        **kwargs: Any,
+    ) -> bytes | Iterator[Any] | None:
+        """Return the content of a file for a commit.
+
+        Args:
+            file_path: Path of the file to return
+            ref: ID of the commit
+            streamed: If True the data will be processed by chunks of
+                `chunk_size` and each chunk is passed to `action` for
+                treatment
+            action: Callable responsible for dealing with each chunk of
+                data
+            chunk_size: Size of each chunk
+            iterator: If True directly return the underlying response
+                iterator
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabGetError: If the file could not be retrieved
+
+        Returns:
+            The file content
+        """
+        file_path = utils.EncodedId(file_path)
+        path = f"{self.path}/{file_path}/raw"
+        if ref is not None:
+            query_data = {"ref": ref}
+        else:
+            query_data = None
+        result = self.gitlab.http_get(
+            path, query_data=query_data, streamed=streamed, raw=True, **kwargs
+        )
+        if TYPE_CHECKING:
+            assert isinstance(result, requests.Response)
+        return utils.response_content(
+            result, streamed, action, chunk_size, iterator=iterator
+        )
+
+    @cli.register_custom_action(
+        cls_names="ProjectFileManager", required=("file_path", "ref")
+    )
+    @exc.on_http_error(exc.GitlabListError)
+    def blame(self, file_path: str, ref: str, **kwargs: Any) -> list[dict[str, Any]]:
+        """Return the content of a file for a commit.
+
+        Args:
+            file_path: Path of the file to retrieve
+            ref: Name of the branch, tag or commit
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabListError:  If the server failed to perform the request
+
+        Returns:
+            A list of commits/lines matching the file
+        """
+        file_path = utils.EncodedId(file_path)
+        path = f"{self.path}/{file_path}/blame"
+        query_data = {"ref": ref}
+        result = self.gitlab.http_list(path, query_data, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(result, list)
+        return result
diff --git a/gitlab/v4/objects/geo_nodes.py b/gitlab/v4/objects/geo_nodes.py
new file mode 100644
index 000000000..754abdf45
--- /dev/null
+++ b/gitlab/v4/objects/geo_nodes.py
@@ -0,0 +1,106 @@
+from typing import Any, Dict, List, TYPE_CHECKING
+
+from gitlab import cli
+from gitlab import exceptions as exc
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    DeleteMixin,
+    ObjectDeleteMixin,
+    RetrieveMixin,
+    SaveMixin,
+    UpdateMixin,
+)
+from gitlab.types import RequiredOptional
+
+__all__ = ["GeoNode", "GeoNodeManager"]
+
+
+class GeoNode(SaveMixin, ObjectDeleteMixin, RESTObject):
+    @cli.register_custom_action(cls_names="GeoNode")
+    @exc.on_http_error(exc.GitlabRepairError)
+    def repair(self, **kwargs: Any) -> None:
+        """Repair the OAuth authentication of the geo node.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabRepairError: If the server failed to perform the request
+        """
+        path = f"/geo_nodes/{self.encoded_id}/repair"
+        server_data = self.manager.gitlab.http_post(path, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+        self._update_attrs(server_data)
+
+    @cli.register_custom_action(cls_names="GeoNode")
+    @exc.on_http_error(exc.GitlabGetError)
+    def status(self, **kwargs: Any) -> Dict[str, Any]:
+        """Get the status of the geo node.
+
+        Args:
+            **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:
+            The status of the geo node
+        """
+        path = f"/geo_nodes/{self.encoded_id}/status"
+        result = self.manager.gitlab.http_get(path, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(result, dict)
+        return result
+
+
+class GeoNodeManager(
+    RetrieveMixin[GeoNode], UpdateMixin[GeoNode], DeleteMixin[GeoNode]
+):
+    _path = "/geo_nodes"
+    _obj_cls = GeoNode
+    _update_attrs = RequiredOptional(
+        optional=("enabled", "url", "files_max_capacity", "repos_max_capacity")
+    )
+
+    @cli.register_custom_action(cls_names="GeoNodeManager")
+    @exc.on_http_error(exc.GitlabGetError)
+    def status(self, **kwargs: Any) -> List[Dict[str, Any]]:
+        """Get the status of all the geo nodes.
+
+        Args:
+            **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:
+            The status of all the geo nodes
+        """
+        result = self.gitlab.http_list("/geo_nodes/status", **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(result, list)
+        return result
+
+    @cli.register_custom_action(cls_names="GeoNodeManager")
+    @exc.on_http_error(exc.GitlabGetError)
+    def current_failures(self, **kwargs: Any) -> List[Dict[str, Any]]:
+        """Get the list of failures on the current geo node.
+
+        Args:
+            **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:
+            The list of failures
+        """
+        result = self.gitlab.http_list("/geo_nodes/current/failures", **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(result, list)
+        return result
diff --git a/gitlab/v4/objects/group_access_tokens.py b/gitlab/v4/objects/group_access_tokens.py
new file mode 100644
index 000000000..65a9d6000
--- /dev/null
+++ b/gitlab/v4/objects/group_access_tokens.py
@@ -0,0 +1,31 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    CreateMixin,
+    DeleteMixin,
+    ObjectDeleteMixin,
+    ObjectRotateMixin,
+    RetrieveMixin,
+    RotateMixin,
+)
+from gitlab.types import ArrayAttribute, RequiredOptional
+
+__all__ = ["GroupAccessToken", "GroupAccessTokenManager"]
+
+
+class GroupAccessToken(ObjectDeleteMixin, ObjectRotateMixin, RESTObject):
+    pass
+
+
+class GroupAccessTokenManager(
+    CreateMixin[GroupAccessToken],
+    DeleteMixin[GroupAccessToken],
+    RetrieveMixin[GroupAccessToken],
+    RotateMixin[GroupAccessToken],
+):
+    _path = "/groups/{group_id}/access_tokens"
+    _obj_cls = GroupAccessToken
+    _from_parent_attrs = {"group_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("name", "scopes"), optional=("access_level", "expires_at")
+    )
+    _types = {"scopes": ArrayAttribute}
diff --git a/gitlab/v4/objects/groups.py b/gitlab/v4/objects/groups.py
new file mode 100644
index 000000000..7a1767817
--- /dev/null
+++ b/gitlab/v4/objects/groups.py
@@ -0,0 +1,457 @@
+from __future__ import annotations
+
+from typing import Any, BinaryIO, TYPE_CHECKING
+
+import requests
+
+import gitlab
+from gitlab import cli
+from gitlab import exceptions as exc
+from gitlab import types
+from gitlab.base import RESTObject, TObjCls
+from gitlab.mixins import (
+    CreateMixin,
+    CRUDMixin,
+    DeleteMixin,
+    ListMixin,
+    NoUpdateMixin,
+    ObjectDeleteMixin,
+    SaveMixin,
+)
+from gitlab.types import RequiredOptional
+
+from .access_requests import GroupAccessRequestManager  # noqa: F401
+from .audit_events import GroupAuditEventManager  # noqa: F401
+from .badges import GroupBadgeManager  # noqa: F401
+from .boards import GroupBoardManager  # noqa: F401
+from .branches import GroupProtectedBranchManager  # noqa: F401
+from .clusters import GroupClusterManager  # noqa: F401
+from .container_registry import GroupRegistryRepositoryManager  # noqa: F401
+from .custom_attributes import GroupCustomAttributeManager  # noqa: F401
+from .deploy_tokens import GroupDeployTokenManager  # noqa: F401
+from .epics import GroupEpicManager  # noqa: F401
+from .export_import import GroupExportManager, GroupImportManager  # noqa: F401
+from .group_access_tokens import GroupAccessTokenManager  # noqa: F401
+from .hooks import GroupHookManager  # noqa: F401
+from .invitations import GroupInvitationManager  # noqa: F401
+from .issues import GroupIssueManager  # noqa: F401
+from .iterations import GroupIterationManager  # noqa: F401
+from .labels import GroupLabelManager  # noqa: F401
+from .member_roles import GroupMemberRoleManager  # noqa: F401
+from .members import (  # noqa: F401
+    GroupBillableMemberManager,
+    GroupMemberAllManager,
+    GroupMemberManager,
+)
+from .merge_request_approvals import GroupApprovalRuleManager
+from .merge_requests import GroupMergeRequestManager  # noqa: F401
+from .milestones import GroupMilestoneManager  # noqa: F401
+from .notification_settings import GroupNotificationSettingsManager  # noqa: F401
+from .packages import GroupPackageManager  # noqa: F401
+from .projects import GroupProjectManager, SharedProjectManager  # noqa: F401
+from .push_rules import GroupPushRulesManager
+from .runners import GroupRunnerManager  # noqa: F401
+from .service_accounts import GroupServiceAccountManager  # noqa: F401
+from .statistics import GroupIssuesStatisticsManager  # noqa: F401
+from .variables import GroupVariableManager  # noqa: F401
+from .wikis import GroupWikiManager  # noqa: F401
+
+__all__ = [
+    "Group",
+    "GroupManager",
+    "GroupDescendantGroup",
+    "GroupDescendantGroupManager",
+    "GroupLDAPGroupLink",
+    "GroupLDAPGroupLinkManager",
+    "GroupSubgroup",
+    "GroupSubgroupManager",
+    "GroupSAMLGroupLink",
+    "GroupSAMLGroupLinkManager",
+]
+
+
+class Group(SaveMixin, ObjectDeleteMixin, RESTObject):
+    _repr_attr = "name"
+
+    access_tokens: GroupAccessTokenManager
+    accessrequests: GroupAccessRequestManager
+    approval_rules: GroupApprovalRuleManager
+    audit_events: GroupAuditEventManager
+    badges: GroupBadgeManager
+    billable_members: GroupBillableMemberManager
+    boards: GroupBoardManager
+    clusters: GroupClusterManager
+    customattributes: GroupCustomAttributeManager
+    deploytokens: GroupDeployTokenManager
+    descendant_groups: GroupDescendantGroupManager
+    epics: GroupEpicManager
+    exports: GroupExportManager
+    hooks: GroupHookManager
+    imports: GroupImportManager
+    invitations: GroupInvitationManager
+    issues: GroupIssueManager
+    issues_statistics: GroupIssuesStatisticsManager
+    iterations: GroupIterationManager
+    labels: GroupLabelManager
+    ldap_group_links: GroupLDAPGroupLinkManager
+    member_roles: GroupMemberRoleManager
+    members: GroupMemberManager
+    members_all: GroupMemberAllManager
+    mergerequests: GroupMergeRequestManager
+    milestones: GroupMilestoneManager
+    notificationsettings: GroupNotificationSettingsManager
+    packages: GroupPackageManager
+    projects: GroupProjectManager
+    shared_projects: SharedProjectManager
+    protectedbranches: GroupProtectedBranchManager
+    pushrules: GroupPushRulesManager
+    registry_repositories: GroupRegistryRepositoryManager
+    runners: GroupRunnerManager
+    subgroups: GroupSubgroupManager
+    variables: GroupVariableManager
+    wikis: GroupWikiManager
+    saml_group_links: GroupSAMLGroupLinkManager
+    service_accounts: GroupServiceAccountManager
+
+    @cli.register_custom_action(cls_names="Group", required=("project_id",))
+    @exc.on_http_error(exc.GitlabTransferProjectError)
+    def transfer_project(self, project_id: int, **kwargs: Any) -> None:
+        """Transfer a project to this group.
+
+        Args:
+            to_project_id: ID of the project to transfer
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabTransferProjectError: If the project could not be transferred
+        """
+        path = f"/groups/{self.encoded_id}/projects/{project_id}"
+        self.manager.gitlab.http_post(path, **kwargs)
+
+    @cli.register_custom_action(cls_names="Group", required=(), optional=("group_id",))
+    @exc.on_http_error(exc.GitlabGroupTransferError)
+    def transfer(self, group_id: int | None = None, **kwargs: Any) -> None:
+        """Transfer the group to a new parent group or make it a top-level group.
+
+        Requires GitLab ≥14.6.
+
+        Args:
+            group_id: ID of the new parent group. When not specified,
+                the group to transfer is instead turned into a top-level group.
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabGroupTransferError: If the group could not be transferred
+        """
+        path = f"/groups/{self.encoded_id}/transfer"
+        post_data = {}
+        if group_id is not None:
+            post_data["group_id"] = group_id
+        self.manager.gitlab.http_post(path, post_data=post_data, **kwargs)
+
+    @cli.register_custom_action(cls_names="Group", required=("scope", "search"))
+    @exc.on_http_error(exc.GitlabSearchError)
+    def search(
+        self, scope: str, search: str, **kwargs: Any
+    ) -> gitlab.GitlabList | list[dict[str, Any]]:
+        """Search the group resources matching the provided string.
+
+        Args:
+            scope: Scope of the search
+            search: Search string
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabSearchError: If the server failed to perform the request
+
+        Returns:
+            A list of dicts describing the resources found.
+        """
+        data = {"scope": scope, "search": search}
+        path = f"/groups/{self.encoded_id}/search"
+        return self.manager.gitlab.http_list(path, query_data=data, **kwargs)
+
+    @cli.register_custom_action(cls_names="Group")
+    @exc.on_http_error(exc.GitlabCreateError)
+    def ldap_sync(self, **kwargs: Any) -> None:
+        """Sync LDAP groups.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabCreateError: If the server cannot perform the request
+        """
+        path = f"/groups/{self.encoded_id}/ldap_sync"
+        self.manager.gitlab.http_post(path, **kwargs)
+
+    @cli.register_custom_action(
+        cls_names="Group",
+        required=("group_id", "group_access"),
+        optional=("expires_at",),
+    )
+    @exc.on_http_error(exc.GitlabCreateError)
+    def share(
+        self,
+        group_id: int,
+        group_access: int,
+        expires_at: str | None = None,
+        **kwargs: Any,
+    ) -> None:
+        """Share the group with a group.
+
+        Args:
+            group_id: ID of the group.
+            group_access: Access level for the group.
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabCreateError: If the server failed to perform the request
+
+        Returns:
+            Group
+        """
+        path = f"/groups/{self.encoded_id}/share"
+        data = {
+            "group_id": group_id,
+            "group_access": group_access,
+            "expires_at": expires_at,
+        }
+        server_data = self.manager.gitlab.http_post(path, post_data=data, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+        self._update_attrs(server_data)
+
+    @cli.register_custom_action(cls_names="Group", required=("group_id",))
+    @exc.on_http_error(exc.GitlabDeleteError)
+    def unshare(self, group_id: int, **kwargs: Any) -> None:
+        """Delete a shared group link within a group.
+
+        Args:
+            group_id: ID of the group.
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabDeleteError: If the server failed to perform the request
+        """
+        path = f"/groups/{self.encoded_id}/share/{group_id}"
+        self.manager.gitlab.http_delete(path, **kwargs)
+
+    @cli.register_custom_action(cls_names="Group")
+    @exc.on_http_error(exc.GitlabRestoreError)
+    def restore(self, **kwargs: Any) -> None:
+        """Restore a  group marked for deletion..
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabRestoreError: If the server failed to perform the request
+        """
+        path = f"/groups/{self.encoded_id}/restore"
+        self.manager.gitlab.http_post(path, **kwargs)
+
+
+class GroupManager(CRUDMixin[Group]):
+    _path = "/groups"
+    _obj_cls = Group
+    _list_filters = (
+        "skip_groups",
+        "all_available",
+        "search",
+        "order_by",
+        "sort",
+        "statistics",
+        "owned",
+        "with_custom_attributes",
+        "min_access_level",
+        "top_level_only",
+    )
+    _create_attrs = RequiredOptional(
+        required=("name", "path"),
+        optional=(
+            "description",
+            "membership_lock",
+            "visibility",
+            "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",
+            "shared_runners_minutes_limit",
+            "extra_shared_runners_minutes_limit",
+        ),
+    )
+    _update_attrs = RequiredOptional(
+        optional=(
+            "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",
+            "file_template_project_id",
+            "shared_runners_minutes_limit",
+            "extra_shared_runners_minutes_limit",
+            "prevent_forking_outside_group",
+            "shared_runners_setting",
+        )
+    )
+    _types = {"avatar": types.ImageAttribute, "skip_groups": types.ArrayAttribute}
+
+    @exc.on_http_error(exc.GitlabImportError)
+    def import_group(
+        self,
+        file: BinaryIO,
+        path: str,
+        name: str,
+        parent_id: int | str | None = None,
+        **kwargs: Any,
+    ) -> dict[str, Any] | requests.Response:
+        """Import a group from an archive file.
+
+        Args:
+            file: Data or file object containing the group
+            path: The path for the new group to be imported.
+            name: The name for the new group.
+            parent_id: 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:
+            A representation of the import status.
+        """
+        files = {"file": ("file.tar.gz", file, "application/octet-stream")}
+        data: dict[str, Any] = {"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 SubgroupBaseManager(ListMixin[TObjCls]):
+    _from_parent_attrs = {"group_id": "id"}
+    _list_filters = (
+        "skip_groups",
+        "all_available",
+        "search",
+        "order_by",
+        "sort",
+        "statistics",
+        "owned",
+        "with_custom_attributes",
+        "min_access_level",
+    )
+    _types = {"skip_groups": types.ArrayAttribute}
+
+
+class GroupSubgroup(RESTObject):
+    pass
+
+
+class GroupSubgroupManager(SubgroupBaseManager[GroupSubgroup]):
+    _path = "/groups/{group_id}/subgroups"
+    _obj_cls = GroupSubgroup
+
+
+class GroupDescendantGroup(RESTObject):
+    pass
+
+
+class GroupDescendantGroupManager(SubgroupBaseManager[GroupDescendantGroup]):
+    """
+    This manager inherits from GroupSubgroupManager as descendant groups
+    share all attributes with subgroups, except the path and object class.
+    """
+
+    _path = "/groups/{group_id}/descendant_groups"
+    _obj_cls = GroupDescendantGroup
+
+
+class GroupLDAPGroupLink(RESTObject):
+    _repr_attr = "provider"
+
+    def _get_link_attrs(self) -> dict[str, str]:
+        # https://docs.gitlab.com/ee/api/groups.html#add-ldap-group-link-with-cn-or-filter
+        # https://docs.gitlab.com/ee/api/groups.html#delete-ldap-group-link-with-cn-or-filter
+        # We can tell what attribute to use based on the data returned
+        data = {"provider": self.provider}
+        if self.cn:
+            data["cn"] = self.cn
+        else:
+            data["filter"] = self.filter
+
+        return data
+
+    def delete(self, **kwargs: Any) -> None:
+        """Delete the LDAP group link from the server.
+
+        Args:
+            **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
+        """
+        if TYPE_CHECKING:
+            assert isinstance(self.manager, DeleteMixin)
+        self.manager.delete(
+            self.encoded_id, query_data=self._get_link_attrs(), **kwargs
+        )
+
+
+class GroupLDAPGroupLinkManager(
+    ListMixin[GroupLDAPGroupLink],
+    CreateMixin[GroupLDAPGroupLink],
+    DeleteMixin[GroupLDAPGroupLink],
+):
+    _path = "/groups/{group_id}/ldap_group_links"
+    _obj_cls = GroupLDAPGroupLink
+    _from_parent_attrs = {"group_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("provider", "group_access"), exclusive=("cn", "filter")
+    )
+
+
+class GroupSAMLGroupLink(ObjectDeleteMixin, RESTObject):
+    _id_attr = "name"
+    _repr_attr = "name"
+
+
+class GroupSAMLGroupLinkManager(NoUpdateMixin[GroupSAMLGroupLink]):
+    _path = "/groups/{group_id}/saml_group_links"
+    _obj_cls = GroupSAMLGroupLink
+    _from_parent_attrs = {"group_id": "id"}
+    _create_attrs = RequiredOptional(required=("saml_group_name", "access_level"))
diff --git a/gitlab/v4/objects/hooks.py b/gitlab/v4/objects/hooks.py
new file mode 100644
index 000000000..f9ce553bb
--- /dev/null
+++ b/gitlab/v4/objects/hooks.py
@@ -0,0 +1,144 @@
+from gitlab import exceptions as exc
+from gitlab.base import RESTObject
+from gitlab.mixins import CRUDMixin, NoUpdateMixin, ObjectDeleteMixin, SaveMixin
+from gitlab.types import RequiredOptional
+
+__all__ = [
+    "Hook",
+    "HookManager",
+    "ProjectHook",
+    "ProjectHookManager",
+    "GroupHook",
+    "GroupHookManager",
+]
+
+
+class Hook(ObjectDeleteMixin, RESTObject):
+    _url = "/hooks"
+    _repr_attr = "url"
+
+
+class HookManager(NoUpdateMixin[Hook]):
+    _path = "/hooks"
+    _obj_cls = Hook
+    _create_attrs = RequiredOptional(required=("url",))
+
+
+class ProjectHook(SaveMixin, ObjectDeleteMixin, RESTObject):
+    _repr_attr = "url"
+
+    @exc.on_http_error(exc.GitlabHookTestError)
+    def test(self, trigger: str) -> None:
+        """
+        Test a Project Hook
+
+        Args:
+            trigger: Type of trigger event to test
+
+        Raises:
+            GitlabHookTestError: If the hook test attempt failed
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/test/{trigger}"
+        self.manager.gitlab.http_post(path)
+
+
+class ProjectHookManager(CRUDMixin[ProjectHook]):
+    _path = "/projects/{project_id}/hooks"
+    _obj_cls = ProjectHook
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("url",),
+        optional=(
+            "push_events",
+            "issues_events",
+            "confidential_issues_events",
+            "merge_requests_events",
+            "tag_push_events",
+            "note_events",
+            "job_events",
+            "pipeline_events",
+            "wiki_page_events",
+            "enable_ssl_verification",
+            "token",
+        ),
+    )
+    _update_attrs = RequiredOptional(
+        required=("url",),
+        optional=(
+            "push_events",
+            "issues_events",
+            "confidential_issues_events",
+            "merge_requests_events",
+            "tag_push_events",
+            "note_events",
+            "job_events",
+            "pipeline_events",
+            "wiki_events",
+            "enable_ssl_verification",
+            "token",
+        ),
+    )
+
+
+class GroupHook(SaveMixin, ObjectDeleteMixin, RESTObject):
+    _repr_attr = "url"
+
+    @exc.on_http_error(exc.GitlabHookTestError)
+    def test(self, trigger: str) -> None:
+        """
+        Test a Group Hook
+
+        Args:
+            trigger: Type of trigger event to test
+
+        Raises:
+            GitlabHookTestError: If the hook test attempt failed
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/test/{trigger}"
+        self.manager.gitlab.http_post(path)
+
+
+class GroupHookManager(CRUDMixin[GroupHook]):
+    _path = "/groups/{group_id}/hooks"
+    _obj_cls = GroupHook
+    _from_parent_attrs = {"group_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("url",),
+        optional=(
+            "push_events",
+            "issues_events",
+            "confidential_issues_events",
+            "merge_requests_events",
+            "tag_push_events",
+            "note_events",
+            "confidential_note_events",
+            "job_events",
+            "pipeline_events",
+            "wiki_page_events",
+            "deployment_events",
+            "releases_events",
+            "subgroup_events",
+            "enable_ssl_verification",
+            "token",
+        ),
+    )
+    _update_attrs = RequiredOptional(
+        required=("url",),
+        optional=(
+            "push_events",
+            "issues_events",
+            "confidential_issues_events",
+            "merge_requests_events",
+            "tag_push_events",
+            "note_events",
+            "confidential_note_events",
+            "job_events",
+            "pipeline_events",
+            "wiki_page_events",
+            "deployment_events",
+            "releases_events",
+            "subgroup_events",
+            "enable_ssl_verification",
+            "token",
+        ),
+    )
diff --git a/gitlab/v4/objects/integrations.py b/gitlab/v4/objects/integrations.py
new file mode 100644
index 000000000..1c2a3ab0a
--- /dev/null
+++ b/gitlab/v4/objects/integrations.py
@@ -0,0 +1,284 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/integrations.html
+"""
+
+from typing import List
+
+from gitlab import cli
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    DeleteMixin,
+    GetMixin,
+    ListMixin,
+    ObjectDeleteMixin,
+    SaveMixin,
+    UpdateMixin,
+)
+
+__all__ = [
+    "ProjectIntegration",
+    "ProjectIntegrationManager",
+    "ProjectService",
+    "ProjectServiceManager",
+]
+
+
+class ProjectIntegration(SaveMixin, ObjectDeleteMixin, RESTObject):
+    _id_attr = "slug"
+
+
+class ProjectIntegrationManager(
+    GetMixin[ProjectIntegration],
+    UpdateMixin[ProjectIntegration],
+    DeleteMixin[ProjectIntegration],
+    ListMixin[ProjectIntegration],
+):
+    _path = "/projects/{project_id}/integrations"
+    _from_parent_attrs = {"project_id": "id"}
+    _obj_cls = ProjectIntegration
+
+    _service_attrs = {
+        "asana": (("api_key",), ("restrict_to_branch", "push_events")),
+        "assembla": (("token",), ("subdomain", "push_events")),
+        "bamboo": (
+            ("bamboo_url", "build_key", "username", "password"),
+            ("push_events",),
+        ),
+        "bugzilla": (
+            ("new_issue_url", "issues_url", "project_url"),
+            ("description", "title", "push_events"),
+        ),
+        "buildkite": (
+            ("token", "project_url"),
+            ("enable_ssl_verification", "push_events"),
+        ),
+        "campfire": (("token",), ("subdomain", "room", "push_events")),
+        "circuit": (
+            ("webhook",),
+            (
+                "notify_only_broken_pipelines",
+                "branches_to_be_notified",
+                "push_events",
+                "issues_events",
+                "confidential_issues_events",
+                "merge_requests_events",
+                "tag_push_events",
+                "note_events",
+                "confidential_note_events",
+                "pipeline_events",
+                "wiki_page_events",
+            ),
+        ),
+        "custom-issue-tracker": (
+            ("new_issue_url", "issues_url", "project_url"),
+            ("description", "title", "push_events"),
+        ),
+        "drone-ci": (
+            ("token", "drone_url"),
+            (
+                "enable_ssl_verification",
+                "push_events",
+                "merge_requests_events",
+                "tag_push_events",
+            ),
+        ),
+        "emails-on-push": (
+            ("recipients",),
+            (
+                "disable_diffs",
+                "send_from_committer_email",
+                "push_events",
+                "tag_push_events",
+                "branches_to_be_notified",
+            ),
+        ),
+        "pipelines-email": (
+            ("recipients",),
+            (
+                "add_pusher",
+                "notify_only_broken_builds",
+                "branches_to_be_notified",
+                "notify_only_default_branch",
+                "pipeline_events",
+            ),
+        ),
+        "external-wiki": (("external_wiki_url",), ()),
+        "flowdock": (("token",), ("push_events",)),
+        "github": (("token", "repository_url"), ("static_context",)),
+        "hangouts-chat": (
+            ("webhook",),
+            (
+                "notify_only_broken_pipelines",
+                "notify_only_default_branch",
+                "branches_to_be_notified",
+                "push_events",
+                "issues_events",
+                "confidential_issues_events",
+                "merge_requests_events",
+                "tag_push_events",
+                "note_events",
+                "confidential_note_events",
+                "pipeline_events",
+                "wiki_page_events",
+            ),
+        ),
+        "hipchat": (
+            ("token",),
+            (
+                "color",
+                "notify",
+                "room",
+                "api_version",
+                "server",
+                "push_events",
+                "issues_events",
+                "confidential_issues_events",
+                "merge_requests_events",
+                "tag_push_events",
+                "note_events",
+                "confidential_note_events",
+                "pipeline_events",
+            ),
+        ),
+        "irker": (
+            ("recipients",),
+            (
+                "default_irc_uri",
+                "server_port",
+                "server_host",
+                "colorize_messages",
+                "push_events",
+            ),
+        ),
+        "jira": (
+            ("url", "username", "password"),
+            (
+                "api_url",
+                "active",
+                "jira_issue_transition_id",
+                "commit_events",
+                "merge_requests_events",
+                "comment_on_event_enabled",
+            ),
+        ),
+        "slack-slash-commands": (("token",), ()),
+        "mattermost-slash-commands": (("token",), ("username",)),
+        "packagist": (
+            ("username", "token"),
+            ("server", "push_events", "merge_requests_events", "tag_push_events"),
+        ),
+        "mattermost": (
+            ("webhook",),
+            (
+                "username",
+                "channel",
+                "notify_only_broken_pipelines",
+                "notify_only_default_branch",
+                "branches_to_be_notified",
+                "push_events",
+                "issues_events",
+                "confidential_issues_events",
+                "merge_requests_events",
+                "tag_push_events",
+                "note_events",
+                "confidential_note_events",
+                "pipeline_events",
+                "wiki_page_events",
+                "push_channel",
+                "issue_channel",
+                "confidential_issue_channel",
+                "merge_request_channel",
+                "note_channel",
+                "confidential_note_channel",
+                "tag_push_channel",
+                "pipeline_channel",
+                "wiki_page_channel",
+            ),
+        ),
+        "pivotaltracker": (("token",), ("restrict_to_branch", "push_events")),
+        "prometheus": (("api_url",), ()),
+        "pushover": (
+            ("api_key", "user_key", "priority"),
+            ("device", "sound", "push_events"),
+        ),
+        "redmine": (
+            ("new_issue_url", "project_url", "issues_url"),
+            ("description", "push_events"),
+        ),
+        "slack": (
+            ("webhook",),
+            (
+                "username",
+                "channel",
+                "notify_only_broken_pipelines",
+                "notify_only_default_branch",
+                "branches_to_be_notified",
+                "commit_events",
+                "confidential_issue_channel",
+                "confidential_issues_events",
+                "confidential_note_channel",
+                "confidential_note_events",
+                "deployment_channel",
+                "deployment_events",
+                "issue_channel",
+                "issues_events",
+                "job_events",
+                "merge_request_channel",
+                "merge_requests_events",
+                "note_channel",
+                "note_events",
+                "pipeline_channel",
+                "pipeline_events",
+                "push_channel",
+                "push_events",
+                "tag_push_channel",
+                "tag_push_events",
+                "wiki_page_channel",
+                "wiki_page_events",
+            ),
+        ),
+        "microsoft-teams": (
+            ("webhook",),
+            (
+                "notify_only_broken_pipelines",
+                "notify_only_default_branch",
+                "branches_to_be_notified",
+                "push_events",
+                "issues_events",
+                "confidential_issues_events",
+                "merge_requests_events",
+                "tag_push_events",
+                "note_events",
+                "confidential_note_events",
+                "pipeline_events",
+                "wiki_page_events",
+            ),
+        ),
+        "teamcity": (
+            ("teamcity_url", "build_type", "username", "password"),
+            ("push_events",),
+        ),
+        "jenkins": (("jenkins_url", "project_name"), ("username", "password")),
+        "mock-ci": (("mock_service_url",), ()),
+        "youtrack": (("issues_url", "project_url"), ("description", "push_events")),
+    }
+
+    @cli.register_custom_action(
+        cls_names=("ProjectIntegrationManager", "ProjectServiceManager")
+    )
+    def available(self) -> List[str]:
+        """List the services known by python-gitlab.
+
+        Returns:
+            The list of service code names.
+        """
+        return list(self._service_attrs.keys())
+
+
+class ProjectService(ProjectIntegration):
+    pass
+
+
+class ProjectServiceManager(ProjectIntegrationManager):
+    _obj_cls = ProjectService
diff --git a/gitlab/v4/objects/invitations.py b/gitlab/v4/objects/invitations.py
new file mode 100644
index 000000000..acfdc09e8
--- /dev/null
+++ b/gitlab/v4/objects/invitations.py
@@ -0,0 +1,80 @@
+from __future__ import annotations
+
+from typing import Any
+
+from gitlab.base import RESTObject, TObjCls
+from gitlab.exceptions import GitlabInvitationError
+from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin
+from gitlab.types import ArrayAttribute, CommaSeparatedListAttribute, RequiredOptional
+
+__all__ = [
+    "ProjectInvitation",
+    "ProjectInvitationManager",
+    "GroupInvitation",
+    "GroupInvitationManager",
+]
+
+
+class InvitationMixin(CRUDMixin[TObjCls]):
+    # pylint: disable=abstract-method
+    def create(self, data: dict[str, Any] | None = None, **kwargs: Any) -> TObjCls:
+        invitation = super().create(data, **kwargs)
+
+        if invitation.status == "error":
+            raise GitlabInvitationError(invitation.message)
+
+        return invitation
+
+
+class ProjectInvitation(SaveMixin, ObjectDeleteMixin, RESTObject):
+    _id_attr = "email"
+
+
+class ProjectInvitationManager(InvitationMixin[ProjectInvitation]):
+    _path = "/projects/{project_id}/invitations"
+    _obj_cls = ProjectInvitation
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("access_level",),
+        optional=(
+            "expires_at",
+            "invite_source",
+            "tasks_to_be_done",
+            "tasks_project_id",
+        ),
+        exclusive=("email", "user_id"),
+    )
+    _update_attrs = RequiredOptional(optional=("access_level", "expires_at"))
+    _list_filters = ("query",)
+    _types = {
+        "email": CommaSeparatedListAttribute,
+        "user_id": CommaSeparatedListAttribute,
+        "tasks_to_be_done": ArrayAttribute,
+    }
+
+
+class GroupInvitation(SaveMixin, ObjectDeleteMixin, RESTObject):
+    _id_attr = "email"
+
+
+class GroupInvitationManager(InvitationMixin[GroupInvitation]):
+    _path = "/groups/{group_id}/invitations"
+    _obj_cls = GroupInvitation
+    _from_parent_attrs = {"group_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("access_level",),
+        optional=(
+            "expires_at",
+            "invite_source",
+            "tasks_to_be_done",
+            "tasks_project_id",
+        ),
+        exclusive=("email", "user_id"),
+    )
+    _update_attrs = RequiredOptional(optional=("access_level", "expires_at"))
+    _list_filters = ("query",)
+    _types = {
+        "email": CommaSeparatedListAttribute,
+        "user_id": CommaSeparatedListAttribute,
+        "tasks_to_be_done": ArrayAttribute,
+    }
diff --git a/gitlab/v4/objects/issues.py b/gitlab/v4/objects/issues.py
new file mode 100644
index 000000000..394eb8614
--- /dev/null
+++ b/gitlab/v4/objects/issues.py
@@ -0,0 +1,325 @@
+from __future__ import annotations
+
+from typing import Any, TYPE_CHECKING
+
+import requests
+
+from gitlab import cli, client
+from gitlab import exceptions as exc
+from gitlab import types
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    CreateMixin,
+    CRUDMixin,
+    DeleteMixin,
+    ListMixin,
+    ObjectDeleteMixin,
+    ParticipantsMixin,
+    RetrieveMixin,
+    SaveMixin,
+    SubscribableMixin,
+    TimeTrackingMixin,
+    TodoMixin,
+    UserAgentDetailMixin,
+)
+from gitlab.types import RequiredOptional
+
+from .award_emojis import ProjectIssueAwardEmojiManager  # noqa: F401
+from .discussions import ProjectIssueDiscussionManager  # noqa: F401
+from .events import (  # noqa: F401
+    ProjectIssueResourceIterationEventManager,
+    ProjectIssueResourceLabelEventManager,
+    ProjectIssueResourceMilestoneEventManager,
+    ProjectIssueResourceStateEventManager,
+    ProjectIssueResourceWeightEventManager,
+)
+from .notes import ProjectIssueNoteManager  # noqa: F401
+
+__all__ = [
+    "Issue",
+    "IssueManager",
+    "GroupIssue",
+    "GroupIssueManager",
+    "ProjectIssue",
+    "ProjectIssueManager",
+    "ProjectIssueLink",
+    "ProjectIssueLinkManager",
+]
+
+
+class Issue(RESTObject):
+    _url = "/issues"
+    _repr_attr = "title"
+
+
+class IssueManager(RetrieveMixin[Issue]):
+    _path = "/issues"
+    _obj_cls = Issue
+    _list_filters = (
+        "state",
+        "labels",
+        "milestone",
+        "scope",
+        "author_id",
+        "iteration_id",
+        "assignee_id",
+        "my_reaction_emoji",
+        "iids",
+        "order_by",
+        "sort",
+        "search",
+        "created_after",
+        "created_before",
+        "updated_after",
+        "updated_before",
+    )
+    _types = {"iids": types.ArrayAttribute, "labels": types.CommaSeparatedListAttribute}
+
+
+class GroupIssue(RESTObject):
+    pass
+
+
+class GroupIssueManager(ListMixin[GroupIssue]):
+    _path = "/groups/{group_id}/issues"
+    _obj_cls = GroupIssue
+    _from_parent_attrs = {"group_id": "id"}
+    _list_filters = (
+        "state",
+        "labels",
+        "milestone",
+        "order_by",
+        "sort",
+        "iids",
+        "author_id",
+        "iteration_id",
+        "assignee_id",
+        "my_reaction_emoji",
+        "search",
+        "created_after",
+        "created_before",
+        "updated_after",
+        "updated_before",
+    )
+    _types = {"iids": types.ArrayAttribute, "labels": types.CommaSeparatedListAttribute}
+
+
+class ProjectIssue(
+    UserAgentDetailMixin,
+    SubscribableMixin,
+    TodoMixin,
+    TimeTrackingMixin,
+    ParticipantsMixin,
+    SaveMixin,
+    ObjectDeleteMixin,
+    RESTObject,
+):
+    _repr_attr = "title"
+    _id_attr = "iid"
+
+    awardemojis: ProjectIssueAwardEmojiManager
+    discussions: ProjectIssueDiscussionManager
+    links: ProjectIssueLinkManager
+    notes: ProjectIssueNoteManager
+    resourcelabelevents: ProjectIssueResourceLabelEventManager
+    resourcemilestoneevents: ProjectIssueResourceMilestoneEventManager
+    resourcestateevents: ProjectIssueResourceStateEventManager
+    resource_iteration_events: ProjectIssueResourceIterationEventManager
+    resource_weight_events: ProjectIssueResourceWeightEventManager
+
+    @cli.register_custom_action(cls_names="ProjectIssue", required=("to_project_id",))
+    @exc.on_http_error(exc.GitlabUpdateError)
+    def move(self, to_project_id: int, **kwargs: Any) -> None:
+        """Move the issue to another project.
+
+        Args:
+            to_project_id: ID of the target project
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabUpdateError: If the issue could not be moved
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/move"
+        data = {"to_project_id": to_project_id}
+        server_data = self.manager.gitlab.http_post(path, post_data=data, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+        self._update_attrs(server_data)
+
+    @cli.register_custom_action(
+        cls_names="ProjectIssue", required=("move_after_id", "move_before_id")
+    )
+    @exc.on_http_error(exc.GitlabUpdateError)
+    def reorder(
+        self,
+        move_after_id: int | None = None,
+        move_before_id: int | None = None,
+        **kwargs: Any,
+    ) -> None:
+        """Reorder an issue on a board.
+
+        Args:
+            move_after_id: ID of an issue that should be placed after this issue
+            move_before_id: ID of an issue that should be placed before this issue
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabUpdateError: If the issue could not be reordered
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/reorder"
+        data: dict[str, Any] = {}
+
+        if move_after_id is not None:
+            data["move_after_id"] = move_after_id
+        if move_before_id is not None:
+            data["move_before_id"] = move_before_id
+
+        server_data = self.manager.gitlab.http_put(path, post_data=data, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+        self._update_attrs(server_data)
+
+    @cli.register_custom_action(cls_names="ProjectIssue")
+    @exc.on_http_error(exc.GitlabGetError)
+    def related_merge_requests(
+        self, **kwargs: Any
+    ) -> client.GitlabList | list[dict[str, Any]]:
+        """List merge requests related to the issue.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabGetErrot: If the merge requests could not be retrieved
+
+        Returns:
+            The list of merge requests.
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/related_merge_requests"
+        result = self.manager.gitlab.http_list(path, **kwargs)
+        if TYPE_CHECKING:
+            assert not isinstance(result, requests.Response)
+        return result
+
+    @cli.register_custom_action(cls_names="ProjectIssue")
+    @exc.on_http_error(exc.GitlabGetError)
+    def closed_by(self, **kwargs: Any) -> client.GitlabList | list[dict[str, Any]]:
+        """List merge requests that will close the issue when merged.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabGetErrot: If the merge requests could not be retrieved
+
+        Returns:
+            The list of merge requests.
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/closed_by"
+        result = self.manager.gitlab.http_list(path, **kwargs)
+        if TYPE_CHECKING:
+            assert not isinstance(result, requests.Response)
+        return result
+
+
+class ProjectIssueManager(CRUDMixin[ProjectIssue]):
+    _path = "/projects/{project_id}/issues"
+    _obj_cls = ProjectIssue
+    _from_parent_attrs = {"project_id": "id"}
+    _list_filters = (
+        "iids",
+        "state",
+        "labels",
+        "milestone",
+        "scope",
+        "author_id",
+        "iteration_id",
+        "assignee_id",
+        "my_reaction_emoji",
+        "order_by",
+        "sort",
+        "search",
+        "created_after",
+        "created_before",
+        "updated_after",
+        "updated_before",
+    )
+    _create_attrs = RequiredOptional(
+        required=("title",),
+        optional=(
+            "description",
+            "confidential",
+            "assignee_ids",
+            "assignee_id",
+            "milestone_id",
+            "labels",
+            "created_at",
+            "due_date",
+            "merge_request_to_resolve_discussions_of",
+            "discussion_to_resolve",
+        ),
+    )
+    _update_attrs = RequiredOptional(
+        optional=(
+            "title",
+            "description",
+            "confidential",
+            "assignee_ids",
+            "assignee_id",
+            "milestone_id",
+            "labels",
+            "state_event",
+            "updated_at",
+            "due_date",
+            "discussion_locked",
+        )
+    )
+    _types = {"iids": types.ArrayAttribute, "labels": types.CommaSeparatedListAttribute}
+
+
+class ProjectIssueLink(ObjectDeleteMixin, RESTObject):
+    _id_attr = "issue_link_id"
+
+
+class ProjectIssueLinkManager(
+    ListMixin[ProjectIssueLink],
+    CreateMixin[ProjectIssueLink],
+    DeleteMixin[ProjectIssueLink],
+):
+    _path = "/projects/{project_id}/issues/{issue_iid}/links"
+    _obj_cls = ProjectIssueLink
+    _from_parent_attrs = {"project_id": "project_id", "issue_iid": "iid"}
+    _create_attrs = RequiredOptional(required=("target_project_id", "target_issue_iid"))
+
+    @exc.on_http_error(exc.GitlabCreateError)
+    # NOTE(jlvillal): Signature doesn't match CreateMixin.create() so ignore
+    # type error
+    def create(  # type: ignore[override]
+        self, data: dict[str, Any], **kwargs: Any
+    ) -> tuple[ProjectIssue, ProjectIssue]:
+        """Create a new object.
+
+        Args:
+            data: parameters to send to the server to create the
+                         resource
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Returns:
+            The source and target issues
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabCreateError: If the server cannot perform the request
+        """
+        self._create_attrs.validate_attrs(data=data)
+        server_data = self.gitlab.http_post(self.path, post_data=data, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+            assert self._parent is not None
+        source_issue = ProjectIssue(self._parent.manager, server_data["source_issue"])
+        target_issue = ProjectIssue(self._parent.manager, server_data["target_issue"])
+        return source_issue, target_issue
diff --git a/gitlab/v4/objects/iterations.py b/gitlab/v4/objects/iterations.py
new file mode 100644
index 000000000..6b5350803
--- /dev/null
+++ b/gitlab/v4/objects/iterations.py
@@ -0,0 +1,49 @@
+from gitlab import types
+from gitlab.base import RESTObject
+from gitlab.mixins import ListMixin
+
+__all__ = ["ProjectIterationManager", "GroupIteration", "GroupIterationManager"]
+
+
+class GroupIteration(RESTObject):
+    _repr_attr = "title"
+
+
+class GroupIterationManager(ListMixin[GroupIteration]):
+    _path = "/groups/{group_id}/iterations"
+    _obj_cls = GroupIteration
+    _from_parent_attrs = {"group_id": "id"}
+    # When using the API, the "in" keyword collides with python's "in" keyword
+    # raising a SyntaxError.
+    # For this reason, we have to use the query_parameters argument:
+    # group.iterations.list(query_parameters={"in": "title"})
+    _list_filters = (
+        "include_ancestors",
+        "include_descendants",
+        "in",
+        "search",
+        "state",
+        "updated_after",
+        "updated_before",
+    )
+    _types = {"in": types.ArrayAttribute}
+
+
+class ProjectIterationManager(ListMixin[GroupIteration]):
+    _path = "/projects/{project_id}/iterations"
+    _obj_cls = GroupIteration
+    _from_parent_attrs = {"project_id": "id"}
+    # When using the API, the "in" keyword collides with python's "in" keyword
+    # raising a SyntaxError.
+    # For this reason, we have to use the query_parameters argument:
+    # project.iterations.list(query_parameters={"in": "title"})
+    _list_filters = (
+        "include_ancestors",
+        "include_descendants",
+        "in",
+        "search",
+        "state",
+        "updated_after",
+        "updated_before",
+    )
+    _types = {"in": types.ArrayAttribute}
diff --git a/gitlab/v4/objects/job_token_scope.py b/gitlab/v4/objects/job_token_scope.py
new file mode 100644
index 000000000..248bb9566
--- /dev/null
+++ b/gitlab/v4/objects/job_token_scope.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+from typing import cast
+
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    CreateMixin,
+    DeleteMixin,
+    GetWithoutIdMixin,
+    ListMixin,
+    ObjectDeleteMixin,
+    RefreshMixin,
+    SaveMixin,
+    UpdateMethod,
+    UpdateMixin,
+)
+from gitlab.types import RequiredOptional
+
+__all__ = ["ProjectJobTokenScope", "ProjectJobTokenScopeManager"]
+
+
+class ProjectJobTokenScope(RefreshMixin, SaveMixin, RESTObject):
+    _id_attr = None
+
+    allowlist: AllowlistProjectManager
+    groups_allowlist: AllowlistGroupManager
+
+
+class ProjectJobTokenScopeManager(
+    GetWithoutIdMixin[ProjectJobTokenScope], UpdateMixin[ProjectJobTokenScope]
+):
+    _path = "/projects/{project_id}/job_token_scope"
+    _obj_cls = ProjectJobTokenScope
+    _from_parent_attrs = {"project_id": "id"}
+    _update_method = UpdateMethod.PATCH
+
+
+class AllowlistProject(ObjectDeleteMixin, RESTObject):
+    _id_attr = "target_project_id"  # note: only true for create endpoint
+
+    def get_id(self) -> int:
+        """Returns the id of the resource. This override deals with
+        the fact that either an `id` or a `target_project_id` attribute
+        is returned by the server depending on the endpoint called."""
+        target_project_id = cast(int, super().get_id())
+        if target_project_id is not None:
+            return target_project_id
+        return cast(int, self.id)
+
+
+class AllowlistProjectManager(
+    ListMixin[AllowlistProject],
+    CreateMixin[AllowlistProject],
+    DeleteMixin[AllowlistProject],
+):
+    _path = "/projects/{project_id}/job_token_scope/allowlist"
+    _obj_cls = AllowlistProject
+    _from_parent_attrs = {"project_id": "project_id"}
+    _create_attrs = RequiredOptional(required=("target_project_id",))
+
+
+class AllowlistGroup(ObjectDeleteMixin, RESTObject):
+    _id_attr = "target_group_id"  # note: only true for create endpoint
+
+    def get_id(self) -> int:
+        """Returns the id of the resource. This override deals with
+        the fact that either an `id` or a `target_group_id` attribute
+        is returned by the server depending on the endpoint called."""
+        target_group_id = cast(int, super().get_id())
+        if target_group_id is not None:
+            return target_group_id
+        return cast(int, self.id)
+
+
+class AllowlistGroupManager(
+    ListMixin[AllowlistGroup], CreateMixin[AllowlistGroup], DeleteMixin[AllowlistGroup]
+):
+    _path = "/projects/{project_id}/job_token_scope/groups_allowlist"
+    _obj_cls = AllowlistGroup
+    _from_parent_attrs = {"project_id": "project_id"}
+    _create_attrs = RequiredOptional(required=("target_group_id",))
diff --git a/gitlab/v4/objects/jobs.py b/gitlab/v4/objects/jobs.py
new file mode 100644
index 000000000..6aa6fc460
--- /dev/null
+++ b/gitlab/v4/objects/jobs.py
@@ -0,0 +1,350 @@
+from __future__ import annotations
+
+from typing import Any, Callable, Iterator, Literal, overload, TYPE_CHECKING
+
+import requests
+
+from gitlab import cli
+from gitlab import exceptions as exc
+from gitlab import utils
+from gitlab.base import RESTObject
+from gitlab.mixins import RefreshMixin, RetrieveMixin
+from gitlab.types import ArrayAttribute
+
+__all__ = ["ProjectJob", "ProjectJobManager"]
+
+
+class ProjectJob(RefreshMixin, RESTObject):
+    @cli.register_custom_action(cls_names="ProjectJob")
+    @exc.on_http_error(exc.GitlabJobCancelError)
+    def cancel(self, **kwargs: Any) -> dict[str, Any]:
+        """Cancel the job.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabJobCancelError: If the job could not be canceled
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/cancel"
+        result = self.manager.gitlab.http_post(path, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(result, dict)
+        return result
+
+    @cli.register_custom_action(cls_names="ProjectJob")
+    @exc.on_http_error(exc.GitlabJobRetryError)
+    def retry(self, **kwargs: Any) -> dict[str, Any]:
+        """Retry the job.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabJobRetryError: If the job could not be retried
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/retry"
+        result = self.manager.gitlab.http_post(path, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(result, dict)
+        return result
+
+    @cli.register_custom_action(cls_names="ProjectJob")
+    @exc.on_http_error(exc.GitlabJobPlayError)
+    def play(self, **kwargs: Any) -> None:
+        """Trigger a job explicitly.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabJobPlayError: If the job could not be triggered
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/play"
+        result = self.manager.gitlab.http_post(path, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(result, dict)
+        self._update_attrs(result)
+
+    @cli.register_custom_action(cls_names="ProjectJob")
+    @exc.on_http_error(exc.GitlabJobEraseError)
+    def erase(self, **kwargs: Any) -> None:
+        """Erase the job (remove job artifacts and trace).
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabJobEraseError: If the job could not be erased
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/erase"
+        self.manager.gitlab.http_post(path, **kwargs)
+
+    @cli.register_custom_action(cls_names="ProjectJob")
+    @exc.on_http_error(exc.GitlabCreateError)
+    def keep_artifacts(self, **kwargs: Any) -> None:
+        """Prevent artifacts from being deleted when expiration is set.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabCreateError: If the request could not be performed
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/artifacts/keep"
+        self.manager.gitlab.http_post(path, **kwargs)
+
+    @cli.register_custom_action(cls_names="ProjectJob")
+    @exc.on_http_error(exc.GitlabCreateError)
+    def delete_artifacts(self, **kwargs: Any) -> None:
+        """Delete artifacts of a job.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabDeleteError: If the request could not be performed
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/artifacts"
+        self.manager.gitlab.http_delete(path, **kwargs)
+
+    @overload
+    def artifacts(
+        self,
+        streamed: Literal[False] = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> bytes: ...
+
+    @overload
+    def artifacts(
+        self,
+        streamed: bool = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[True] = True,
+        **kwargs: Any,
+    ) -> Iterator[Any]: ...
+
+    @overload
+    def artifacts(
+        self,
+        streamed: Literal[True] = True,
+        action: Callable[[bytes], Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> None: ...
+
+    @cli.register_custom_action(cls_names="ProjectJob")
+    @exc.on_http_error(exc.GitlabGetError)
+    def artifacts(
+        self,
+        streamed: bool = False,
+        action: Callable[..., Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: bool = False,
+        **kwargs: Any,
+    ) -> bytes | Iterator[Any] | None:
+        """Get the job artifacts.
+
+        Args:
+            streamed: If True the data will be processed by chunks of
+                `chunk_size` and each chunk is passed to `action` for
+                treatment
+            iterator: If True directly return the underlying response
+                iterator
+            action: Callable responsible of dealing with chunk of
+                data
+            chunk_size: 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 artifacts could not be retrieved
+
+        Returns:
+            The artifacts if `streamed` is False, None otherwise.
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/artifacts"
+        result = self.manager.gitlab.http_get(
+            path, streamed=streamed, raw=True, **kwargs
+        )
+        if TYPE_CHECKING:
+            assert isinstance(result, requests.Response)
+        return utils.response_content(
+            result, streamed, action, chunk_size, iterator=iterator
+        )
+
+    @overload
+    def artifact(
+        self,
+        path: str,
+        streamed: Literal[False] = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> bytes: ...
+
+    @overload
+    def artifact(
+        self,
+        path: str,
+        streamed: bool = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[True] = True,
+        **kwargs: Any,
+    ) -> Iterator[Any]: ...
+
+    @overload
+    def artifact(
+        self,
+        path: str,
+        streamed: Literal[True] = True,
+        action: Callable[[bytes], Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> None: ...
+
+    @cli.register_custom_action(cls_names="ProjectJob")
+    @exc.on_http_error(exc.GitlabGetError)
+    def artifact(
+        self,
+        path: str,
+        streamed: bool = False,
+        action: Callable[..., Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: bool = False,
+        **kwargs: Any,
+    ) -> bytes | Iterator[Any] | None:
+        """Get a single artifact file from within the job's artifacts archive.
+
+        Args:
+            path: Path of the artifact
+            streamed: If True the data will be processed by chunks of
+                `chunk_size` and each chunk is passed to `action` for
+                treatment
+            iterator: If True directly return the underlying response
+                iterator
+            action: Callable responsible of dealing with chunk of
+                data
+            chunk_size: 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 artifacts could not be retrieved
+
+        Returns:
+            The artifacts if `streamed` is False, None otherwise.
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/artifacts/{path}"
+        result = self.manager.gitlab.http_get(
+            path, streamed=streamed, raw=True, **kwargs
+        )
+        if TYPE_CHECKING:
+            assert isinstance(result, requests.Response)
+        return utils.response_content(
+            result, streamed, action, chunk_size, iterator=iterator
+        )
+
+    @overload
+    def trace(
+        self,
+        streamed: Literal[False] = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> bytes: ...
+
+    @overload
+    def trace(
+        self,
+        streamed: bool = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[True] = True,
+        **kwargs: Any,
+    ) -> Iterator[Any]: ...
+
+    @overload
+    def trace(
+        self,
+        streamed: Literal[True] = True,
+        action: Callable[[bytes], Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> None: ...
+
+    @cli.register_custom_action(cls_names="ProjectJob")
+    @exc.on_http_error(exc.GitlabGetError)
+    def trace(
+        self,
+        streamed: bool = False,
+        action: Callable[..., Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: bool = False,
+        **kwargs: Any,
+    ) -> bytes | Iterator[Any] | None:
+        """Get the job trace.
+
+        Args:
+            streamed: If True the data will be processed by chunks of
+                `chunk_size` and each chunk is passed to `action` for
+                treatment
+            iterator: If True directly return the underlying response
+                iterator
+            action: Callable responsible of dealing with chunk of
+                data
+            chunk_size: 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 artifacts could not be retrieved
+
+        Returns:
+            The trace
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/trace"
+        result = self.manager.gitlab.http_get(
+            path, streamed=streamed, raw=True, **kwargs
+        )
+        if TYPE_CHECKING:
+            assert isinstance(result, requests.Response)
+        return utils.response_content(
+            result, streamed, action, chunk_size, iterator=iterator
+        )
+
+
+class ProjectJobManager(RetrieveMixin[ProjectJob]):
+    _path = "/projects/{project_id}/jobs"
+    _obj_cls = ProjectJob
+    _from_parent_attrs = {"project_id": "id"}
+    _list_filters = ("scope",)
+    _types = {"scope": ArrayAttribute}
diff --git a/gitlab/v4/objects/keys.py b/gitlab/v4/objects/keys.py
new file mode 100644
index 000000000..8511b1b58
--- /dev/null
+++ b/gitlab/v4/objects/keys.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+from typing import Any, TYPE_CHECKING
+
+from gitlab.base import RESTObject
+from gitlab.mixins import GetMixin
+
+__all__ = ["Key", "KeyManager"]
+
+
+class Key(RESTObject):
+    pass
+
+
+class KeyManager(GetMixin[Key]):
+    _path = "/keys"
+    _obj_cls = Key
+
+    def get(
+        self, id: int | str | None = None, lazy: bool = False, **kwargs: Any
+    ) -> Key:
+        if id is not None:
+            return super().get(id, lazy=lazy, **kwargs)
+
+        if "fingerprint" not in kwargs:
+            raise AttributeError("Missing attribute: id or fingerprint")
+
+        server_data = self.gitlab.http_get(self.path, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+        return self._obj_cls(self, server_data)
diff --git a/gitlab/v4/objects/labels.py b/gitlab/v4/objects/labels.py
new file mode 100644
index 000000000..c9514c998
--- /dev/null
+++ b/gitlab/v4/objects/labels.py
@@ -0,0 +1,139 @@
+from __future__ import annotations
+
+from typing import Any
+
+from gitlab import exceptions as exc
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    CreateMixin,
+    DeleteMixin,
+    ObjectDeleteMixin,
+    PromoteMixin,
+    RetrieveMixin,
+    SaveMixin,
+    SubscribableMixin,
+    UpdateMixin,
+)
+from gitlab.types import RequiredOptional
+
+__all__ = ["GroupLabel", "GroupLabelManager", "ProjectLabel", "ProjectLabelManager"]
+
+
+class GroupLabel(SubscribableMixin, SaveMixin, ObjectDeleteMixin, RESTObject):
+    _id_attr = "name"
+    manager: GroupLabelManager
+
+    # Update without ID, but we need an ID to get from list.
+    @exc.on_http_error(exc.GitlabUpdateError)
+    def save(self, **kwargs: Any) -> None:
+        """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 GroupLabelManager(
+    RetrieveMixin[GroupLabel],
+    CreateMixin[GroupLabel],
+    UpdateMixin[GroupLabel],
+    DeleteMixin[GroupLabel],
+):
+    _path = "/groups/{group_id}/labels"
+    _obj_cls = GroupLabel
+    _from_parent_attrs = {"group_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("name", "color"), optional=("description", "priority")
+    )
+    _update_attrs = RequiredOptional(
+        required=("name",), optional=("new_name", "color", "description", "priority")
+    )
+
+    # Update without ID.
+    # NOTE(jlvillal): Signature doesn't match UpdateMixin.update() so ignore
+    # type error
+    def update(  # type: ignore[override]
+        self, name: str | None, new_data: dict[str, Any] | None = None, **kwargs: Any
+    ) -> dict[str, Any]:
+        """Update a Label on the server.
+
+        Args:
+            name: The name of the label
+            **kwargs: Extra options to send to the server (e.g. sudo)
+        """
+        new_data = new_data or {}
+        if name:
+            new_data["name"] = name
+        return super().update(id=None, new_data=new_data, **kwargs)
+
+
+class ProjectLabel(
+    PromoteMixin, SubscribableMixin, SaveMixin, ObjectDeleteMixin, RESTObject
+):
+    _id_attr = "name"
+    manager: ProjectLabelManager
+
+    # Update without ID, but we need an ID to get from list.
+    @exc.on_http_error(exc.GitlabUpdateError)
+    def save(self, **kwargs: Any) -> None:
+        """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(
+    RetrieveMixin[ProjectLabel],
+    CreateMixin[ProjectLabel],
+    UpdateMixin[ProjectLabel],
+    DeleteMixin[ProjectLabel],
+):
+    _path = "/projects/{project_id}/labels"
+    _obj_cls = ProjectLabel
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("name", "color"), optional=("description", "priority")
+    )
+    _update_attrs = RequiredOptional(
+        required=("name",), optional=("new_name", "color", "description", "priority")
+    )
+
+    # Update without ID.
+    # NOTE(jlvillal): Signature doesn't match UpdateMixin.update() so ignore
+    # type error
+    def update(  # type: ignore[override]
+        self, name: str | None, new_data: dict[str, Any] | None = None, **kwargs: Any
+    ) -> dict[str, Any]:
+        """Update a Label on the server.
+
+        Args:
+            name: The name of the label
+            **kwargs: Extra options to send to the server (e.g. sudo)
+        """
+        new_data = new_data or {}
+        if name:
+            new_data["name"] = name
+        return super().update(id=None, new_data=new_data, **kwargs)
diff --git a/gitlab/v4/objects/ldap.py b/gitlab/v4/objects/ldap.py
new file mode 100644
index 000000000..8b9c88f4f
--- /dev/null
+++ b/gitlab/v4/objects/ldap.py
@@ -0,0 +1,68 @@
+from __future__ import annotations
+
+from typing import Any, Literal, overload
+
+from gitlab import exceptions as exc
+from gitlab.base import RESTManager, RESTObject, RESTObjectList
+
+__all__ = ["LDAPGroup", "LDAPGroupManager"]
+
+
+class LDAPGroup(RESTObject):
+    _id_attr = None
+
+
+class LDAPGroupManager(RESTManager[LDAPGroup]):
+    _path = "/ldap/groups"
+    _obj_cls = LDAPGroup
+    _list_filters = ("search", "provider")
+
+    @overload
+    def list(
+        self, *, iterator: Literal[False] = False, **kwargs: Any
+    ) -> list[LDAPGroup]: ...
+
+    @overload
+    def list(
+        self, *, iterator: Literal[True] = True, **kwargs: Any
+    ) -> RESTObjectList[LDAPGroup]: ...
+
+    @overload
+    def list(
+        self, *, iterator: bool = False, **kwargs: Any
+    ) -> list[LDAPGroup] | RESTObjectList[LDAPGroup]: ...
+
+    @exc.on_http_error(exc.GitlabListError)
+    def list(
+        self, *, iterator: bool = False, **kwargs: Any
+    ) -> list[LDAPGroup] | RESTObjectList[LDAPGroup]:
+        """Retrieve a list of objects.
+
+        Args:
+            get_all: If True, return all the items, without pagination
+            per_page: Number of items to retrieve per request
+            page: ID of the page to return (starts with page 1)
+            iterator: If set to True and no pagination option is
+                defined, return a generator instead of a list
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Returns:
+            The list of objects, or a generator if `iterator` is True
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabListError: If the server cannot perform the request
+        """
+        data = kwargs.copy()
+        if self.gitlab.per_page:
+            data.setdefault("per_page", self.gitlab.per_page)
+
+        if "provider" in data:
+            path = f"/ldap/{data['provider']}/groups"
+        else:
+            path = self._path
+
+        obj = self.gitlab.http_list(path, iterator=iterator, **data)
+        if isinstance(obj, list):
+            return [self._obj_cls(self, item) for item in obj]
+        return RESTObjectList(self, self._obj_cls, obj)
diff --git a/gitlab/v4/objects/member_roles.py b/gitlab/v4/objects/member_roles.py
new file mode 100644
index 000000000..73c5c6644
--- /dev/null
+++ b/gitlab/v4/objects/member_roles.py
@@ -0,0 +1,102 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/instance_level_ci_variables.html
+https://docs.gitlab.com/ee/api/project_level_variables.html
+https://docs.gitlab.com/ee/api/group_level_variables.html
+"""
+
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    CreateMixin,
+    DeleteMixin,
+    ListMixin,
+    ObjectDeleteMixin,
+    SaveMixin,
+)
+from gitlab.types import RequiredOptional
+
+__all__ = [
+    "MemberRole",
+    "MemberRoleManager",
+    "GroupMemberRole",
+    "GroupMemberRoleManager",
+]
+
+
+class MemberRole(SaveMixin, ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class MemberRoleManager(
+    ListMixin[MemberRole], CreateMixin[MemberRole], DeleteMixin[MemberRole]
+):
+    _path = "/member_roles"
+    _obj_cls = MemberRole
+    _create_attrs = RequiredOptional(
+        required=("name", "base_access_level"),
+        optional=(
+            "description",
+            "admin_cicd_variables",
+            "admin_compliance_framework",
+            "admin_group_member",
+            "admin_group_member",
+            "admin_merge_request",
+            "admin_push_rules",
+            "admin_terraform_state",
+            "admin_vulnerability",
+            "admin_web_hook",
+            "archive_project",
+            "manage_deploy_tokens",
+            "manage_group_access_tokens",
+            "manage_merge_request_settings",
+            "manage_project_access_tokens",
+            "manage_security_policy_link",
+            "read_code",
+            "read_runners",
+            "read_dependency",
+            "read_vulnerability",
+            "remove_group",
+            "remove_project",
+        ),
+    )
+
+
+class GroupMemberRole(SaveMixin, ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class GroupMemberRoleManager(
+    ListMixin[GroupMemberRole],
+    CreateMixin[GroupMemberRole],
+    DeleteMixin[GroupMemberRole],
+):
+    _path = "/groups/{group_id}/member_roles"
+    _from_parent_attrs = {"group_id": "id"}
+    _obj_cls = GroupMemberRole
+    _create_attrs = RequiredOptional(
+        required=("name", "base_access_level"),
+        optional=(
+            "description",
+            "admin_cicd_variables",
+            "admin_compliance_framework",
+            "admin_group_member",
+            "admin_group_member",
+            "admin_merge_request",
+            "admin_push_rules",
+            "admin_terraform_state",
+            "admin_vulnerability",
+            "admin_web_hook",
+            "archive_project",
+            "manage_deploy_tokens",
+            "manage_group_access_tokens",
+            "manage_merge_request_settings",
+            "manage_project_access_tokens",
+            "manage_security_policy_link",
+            "read_code",
+            "read_runners",
+            "read_dependency",
+            "read_vulnerability",
+            "remove_group",
+            "remove_project",
+        ),
+    )
diff --git a/gitlab/v4/objects/members.py b/gitlab/v4/objects/members.py
new file mode 100644
index 000000000..918e3c4ed
--- /dev/null
+++ b/gitlab/v4/objects/members.py
@@ -0,0 +1,117 @@
+from __future__ import annotations
+
+from gitlab import types
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    CRUDMixin,
+    DeleteMixin,
+    ListMixin,
+    ObjectDeleteMixin,
+    RetrieveMixin,
+    SaveMixin,
+)
+from gitlab.types import RequiredOptional
+
+__all__ = [
+    "GroupBillableMember",
+    "GroupBillableMemberManager",
+    "GroupBillableMemberMembership",
+    "GroupBillableMemberMembershipManager",
+    "GroupMember",
+    "GroupMemberAll",
+    "GroupMemberManager",
+    "GroupMemberAllManager",
+    "ProjectMember",
+    "ProjectMemberAll",
+    "ProjectMemberManager",
+    "ProjectMemberAllManager",
+]
+
+
+class GroupMember(SaveMixin, ObjectDeleteMixin, RESTObject):
+    _repr_attr = "username"
+
+
+class GroupMemberManager(CRUDMixin[GroupMember]):
+    _path = "/groups/{group_id}/members"
+    _obj_cls = GroupMember
+    _from_parent_attrs = {"group_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("access_level",),
+        optional=("expires_at", "tasks_to_be_done"),
+        exclusive=("username", "user_id"),
+    )
+    _update_attrs = RequiredOptional(
+        required=("access_level",), optional=("expires_at",)
+    )
+    _types = {
+        "user_ids": types.ArrayAttribute,
+        "tasks_to_be_done": types.ArrayAttribute,
+    }
+
+
+class GroupBillableMember(ObjectDeleteMixin, RESTObject):
+    _repr_attr = "username"
+
+    memberships: GroupBillableMemberMembershipManager
+
+
+class GroupBillableMemberManager(
+    ListMixin[GroupBillableMember], DeleteMixin[GroupBillableMember]
+):
+    _path = "/groups/{group_id}/billable_members"
+    _obj_cls = GroupBillableMember
+    _from_parent_attrs = {"group_id": "id"}
+    _list_filters = ("search", "sort")
+
+
+class GroupBillableMemberMembership(RESTObject):
+    _id_attr = "user_id"
+
+
+class GroupBillableMemberMembershipManager(ListMixin[GroupBillableMemberMembership]):
+    _path = "/groups/{group_id}/billable_members/{user_id}/memberships"
+    _obj_cls = GroupBillableMemberMembership
+    _from_parent_attrs = {"group_id": "group_id", "user_id": "id"}
+
+
+class GroupMemberAll(RESTObject):
+    _repr_attr = "username"
+
+
+class GroupMemberAllManager(RetrieveMixin[GroupMemberAll]):
+    _path = "/groups/{group_id}/members/all"
+    _obj_cls = GroupMemberAll
+    _from_parent_attrs = {"group_id": "id"}
+
+
+class ProjectMember(SaveMixin, ObjectDeleteMixin, RESTObject):
+    _repr_attr = "username"
+
+
+class ProjectMemberManager(CRUDMixin[ProjectMember]):
+    _path = "/projects/{project_id}/members"
+    _obj_cls = ProjectMember
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("access_level",),
+        optional=("expires_at", "tasks_to_be_done"),
+        exclusive=("username", "user_id"),
+    )
+    _update_attrs = RequiredOptional(
+        required=("access_level",), optional=("expires_at",)
+    )
+    _types = {
+        "user_ids": types.ArrayAttribute,
+        "tasks_to_be_dones": types.ArrayAttribute,
+    }
+
+
+class ProjectMemberAll(RESTObject):
+    _repr_attr = "username"
+
+
+class ProjectMemberAllManager(RetrieveMixin[ProjectMemberAll]):
+    _path = "/projects/{project_id}/members/all"
+    _obj_cls = ProjectMemberAll
+    _from_parent_attrs = {"project_id": "id"}
diff --git a/gitlab/v4/objects/merge_request_approvals.py b/gitlab/v4/objects/merge_request_approvals.py
new file mode 100644
index 000000000..6ca324ecf
--- /dev/null
+++ b/gitlab/v4/objects/merge_request_approvals.py
@@ -0,0 +1,196 @@
+from __future__ import annotations
+
+from typing import Any, TYPE_CHECKING
+
+from gitlab import exceptions as exc
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    CreateMixin,
+    CRUDMixin,
+    DeleteMixin,
+    GetWithoutIdMixin,
+    ObjectDeleteMixin,
+    RetrieveMixin,
+    SaveMixin,
+    UpdateMethod,
+    UpdateMixin,
+)
+from gitlab.types import RequiredOptional
+
+__all__ = [
+    "GroupApprovalRule",
+    "GroupApprovalRuleManager",
+    "ProjectApproval",
+    "ProjectApprovalManager",
+    "ProjectApprovalRule",
+    "ProjectApprovalRuleManager",
+    "ProjectMergeRequestApproval",
+    "ProjectMergeRequestApprovalManager",
+    "ProjectMergeRequestApprovalRule",
+    "ProjectMergeRequestApprovalRuleManager",
+    "ProjectMergeRequestApprovalState",
+    "ProjectMergeRequestApprovalStateManager",
+]
+
+
+class GroupApprovalRule(SaveMixin, RESTObject):
+    _id_attr = "id"
+    _repr_attr = "name"
+
+
+class GroupApprovalRuleManager(
+    RetrieveMixin[GroupApprovalRule],
+    CreateMixin[GroupApprovalRule],
+    UpdateMixin[GroupApprovalRule],
+):
+    _path = "/groups/{group_id}/approval_rules"
+    _obj_cls = GroupApprovalRule
+    _from_parent_attrs = {"group_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("name", "approvals_required"),
+        optional=("user_ids", "group_ids", "rule_type"),
+    )
+
+
+class ProjectApproval(SaveMixin, RESTObject):
+    _id_attr = None
+
+
+class ProjectApprovalManager(
+    GetWithoutIdMixin[ProjectApproval], UpdateMixin[ProjectApproval]
+):
+    _path = "/projects/{project_id}/approvals"
+    _obj_cls = ProjectApproval
+    _from_parent_attrs = {"project_id": "id"}
+    _update_attrs = RequiredOptional(
+        optional=(
+            "approvals_before_merge",
+            "reset_approvals_on_push",
+            "disable_overriding_approvers_per_merge_request",
+            "merge_requests_author_approval",
+            "merge_requests_disable_committers_approval",
+        )
+    )
+    _update_method = UpdateMethod.POST
+
+
+class ProjectApprovalRule(SaveMixin, ObjectDeleteMixin, RESTObject):
+    _id_attr = "id"
+    _repr_attr = "name"
+
+
+class ProjectApprovalRuleManager(
+    RetrieveMixin[ProjectApprovalRule],
+    CreateMixin[ProjectApprovalRule],
+    UpdateMixin[ProjectApprovalRule],
+    DeleteMixin[ProjectApprovalRule],
+):
+    _path = "/projects/{project_id}/approval_rules"
+    _obj_cls = ProjectApprovalRule
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("name", "approvals_required"),
+        optional=("user_ids", "group_ids", "protected_branch_ids", "usernames"),
+    )
+
+
+class ProjectMergeRequestApproval(SaveMixin, RESTObject):
+    _id_attr = None
+
+
+class ProjectMergeRequestApprovalManager(
+    GetWithoutIdMixin[ProjectMergeRequestApproval],
+    UpdateMixin[ProjectMergeRequestApproval],
+):
+    _path = "/projects/{project_id}/merge_requests/{mr_iid}/approvals"
+    _obj_cls = ProjectMergeRequestApproval
+    _from_parent_attrs = {"project_id": "project_id", "mr_iid": "iid"}
+    _update_attrs = RequiredOptional(required=("approvals_required",))
+    _update_method = UpdateMethod.POST
+
+    @exc.on_http_error(exc.GitlabUpdateError)
+    def set_approvers(
+        self,
+        approvals_required: int,
+        approver_ids: list[int] | None = None,
+        approver_group_ids: list[int] | None = None,
+        approval_rule_name: str = "name",
+        *,
+        approver_usernames: list[str] | None = None,
+        **kwargs: Any,
+    ) -> RESTObject:
+        """Change MR-level allowed approvers and approver groups.
+
+        Args:
+            approvals_required: The number of required approvals for this rule
+            approver_ids: User IDs that can approve MRs
+            approver_group_ids: Group IDs whose members can approve MRs
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabUpdateError: If the server failed to perform the request
+        """
+        approver_ids = approver_ids or []
+        approver_group_ids = approver_group_ids or []
+        approver_usernames = approver_usernames or []
+
+        data = {
+            "name": approval_rule_name,
+            "approvals_required": approvals_required,
+            "rule_type": "regular",
+            "user_ids": approver_ids,
+            "group_ids": approver_group_ids,
+            "usernames": approver_usernames,
+        }
+        if TYPE_CHECKING:
+            assert self._parent is not None
+        approval_rules: ProjectMergeRequestApprovalRuleManager = (
+            self._parent.approval_rules
+        )
+        # update any existing approval rule matching the name
+        existing_approval_rules = approval_rules.list(iterator=True)
+        for ar in existing_approval_rules:
+            if ar.name == approval_rule_name:
+                ar.user_ids = data["user_ids"]
+                ar.approvals_required = data["approvals_required"]
+                ar.group_ids = data["group_ids"]
+                ar.usernames = data["usernames"]
+                ar.save()
+                return ar
+        # if there was no rule matching the rule name, create a new one
+        return approval_rules.create(data=data, **kwargs)
+
+
+class ProjectMergeRequestApprovalRule(SaveMixin, ObjectDeleteMixin, RESTObject):
+    _repr_attr = "name"
+
+
+class ProjectMergeRequestApprovalRuleManager(
+    CRUDMixin[ProjectMergeRequestApprovalRule]
+):
+    _path = "/projects/{project_id}/merge_requests/{merge_request_iid}/approval_rules"
+    _obj_cls = ProjectMergeRequestApprovalRule
+    _from_parent_attrs = {"project_id": "project_id", "merge_request_iid": "iid"}
+    _update_attrs = RequiredOptional(
+        required=("id", "merge_request_iid", "name", "approvals_required"),
+        optional=("user_ids", "group_ids", "usernames"),
+    )
+    # Important: When approval_project_rule_id is set, the name, users and
+    # groups of project-level rule will be copied. The approvals_required
+    # specified will be used.
+    _create_attrs = RequiredOptional(
+        required=("name", "approvals_required"),
+        optional=("approval_project_rule_id", "user_ids", "group_ids", "usernames"),
+    )
+
+
+class ProjectMergeRequestApprovalState(RESTObject):
+    pass
+
+
+class ProjectMergeRequestApprovalStateManager(
+    GetWithoutIdMixin[ProjectMergeRequestApprovalState]
+):
+    _path = "/projects/{project_id}/merge_requests/{mr_iid}/approval_state"
+    _obj_cls = ProjectMergeRequestApprovalState
+    _from_parent_attrs = {"project_id": "project_id", "mr_iid": "iid"}
diff --git a/gitlab/v4/objects/merge_requests.py b/gitlab/v4/objects/merge_requests.py
new file mode 100644
index 000000000..4ebd03f5b
--- /dev/null
+++ b/gitlab/v4/objects/merge_requests.py
@@ -0,0 +1,538 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/merge_requests.html
+https://docs.gitlab.com/ee/api/merge_request_approvals.html
+"""
+
+from __future__ import annotations
+
+from typing import Any, TYPE_CHECKING
+
+import requests
+
+import gitlab
+from gitlab import cli
+from gitlab import exceptions as exc
+from gitlab import types
+from gitlab.base import RESTObject, RESTObjectList
+from gitlab.mixins import (
+    CRUDMixin,
+    ListMixin,
+    ObjectDeleteMixin,
+    ParticipantsMixin,
+    RetrieveMixin,
+    SaveMixin,
+    SubscribableMixin,
+    TimeTrackingMixin,
+    TodoMixin,
+)
+from gitlab.types import RequiredOptional
+
+from .award_emojis import ProjectMergeRequestAwardEmojiManager  # noqa: F401
+from .commits import ProjectCommit, ProjectCommitManager
+from .discussions import ProjectMergeRequestDiscussionManager  # noqa: F401
+from .draft_notes import ProjectMergeRequestDraftNoteManager
+from .events import (  # noqa: F401
+    ProjectMergeRequestResourceLabelEventManager,
+    ProjectMergeRequestResourceMilestoneEventManager,
+    ProjectMergeRequestResourceStateEventManager,
+)
+from .issues import ProjectIssue, ProjectIssueManager
+from .merge_request_approvals import (  # noqa: F401
+    ProjectMergeRequestApprovalManager,
+    ProjectMergeRequestApprovalRuleManager,
+    ProjectMergeRequestApprovalStateManager,
+)
+from .notes import ProjectMergeRequestNoteManager  # noqa: F401
+from .pipelines import ProjectMergeRequestPipelineManager  # noqa: F401
+from .reviewers import ProjectMergeRequestReviewerDetailManager
+from .status_checks import ProjectMergeRequestStatusCheckManager
+
+__all__ = [
+    "MergeRequest",
+    "MergeRequestManager",
+    "GroupMergeRequest",
+    "GroupMergeRequestManager",
+    "ProjectMergeRequest",
+    "ProjectMergeRequestManager",
+    "ProjectDeploymentMergeRequest",
+    "ProjectDeploymentMergeRequestManager",
+    "ProjectMergeRequestDiff",
+    "ProjectMergeRequestDiffManager",
+]
+
+
+class MergeRequest(RESTObject):
+    pass
+
+
+class MergeRequestManager(ListMixin[MergeRequest]):
+    _path = "/merge_requests"
+    _obj_cls = MergeRequest
+    _list_filters = (
+        "state",
+        "order_by",
+        "sort",
+        "milestone",
+        "view",
+        "labels",
+        "with_labels_details",
+        "with_merge_status_recheck",
+        "created_after",
+        "created_before",
+        "updated_after",
+        "updated_before",
+        "scope",
+        "author_id",
+        "author_username",
+        "assignee_id",
+        "approver_ids",
+        "approved_by_ids",
+        "reviewer_id",
+        "reviewer_username",
+        "my_reaction_emoji",
+        "source_branch",
+        "target_branch",
+        "search",
+        "in",
+        "wip",
+        "not",
+        "environment",
+        "deployed_before",
+        "deployed_after",
+    )
+    _types = {
+        "approver_ids": types.ArrayAttribute,
+        "approved_by_ids": types.ArrayAttribute,
+        "in": types.CommaSeparatedListAttribute,
+        "labels": types.CommaSeparatedListAttribute,
+    }
+
+
+class GroupMergeRequest(RESTObject):
+    pass
+
+
+class GroupMergeRequestManager(ListMixin[GroupMergeRequest]):
+    _path = "/groups/{group_id}/merge_requests"
+    _obj_cls = GroupMergeRequest
+    _from_parent_attrs = {"group_id": "id"}
+    _list_filters = (
+        "state",
+        "order_by",
+        "sort",
+        "milestone",
+        "view",
+        "labels",
+        "created_after",
+        "created_before",
+        "updated_after",
+        "updated_before",
+        "scope",
+        "author_id",
+        "assignee_id",
+        "approver_ids",
+        "approved_by_ids",
+        "my_reaction_emoji",
+        "source_branch",
+        "target_branch",
+        "search",
+        "wip",
+    )
+    _types = {
+        "approver_ids": types.ArrayAttribute,
+        "approved_by_ids": types.ArrayAttribute,
+        "labels": types.CommaSeparatedListAttribute,
+    }
+
+
+class ProjectMergeRequest(
+    SubscribableMixin,
+    TodoMixin,
+    TimeTrackingMixin,
+    ParticipantsMixin,
+    SaveMixin,
+    ObjectDeleteMixin,
+    RESTObject,
+):
+    _id_attr = "iid"
+
+    approval_rules: ProjectMergeRequestApprovalRuleManager
+    approval_state: ProjectMergeRequestApprovalStateManager
+    approvals: ProjectMergeRequestApprovalManager
+    awardemojis: ProjectMergeRequestAwardEmojiManager
+    diffs: ProjectMergeRequestDiffManager
+    discussions: ProjectMergeRequestDiscussionManager
+    draft_notes: ProjectMergeRequestDraftNoteManager
+    notes: ProjectMergeRequestNoteManager
+    pipelines: ProjectMergeRequestPipelineManager
+    resourcelabelevents: ProjectMergeRequestResourceLabelEventManager
+    resourcemilestoneevents: ProjectMergeRequestResourceMilestoneEventManager
+    resourcestateevents: ProjectMergeRequestResourceStateEventManager
+    reviewer_details: ProjectMergeRequestReviewerDetailManager
+    status_checks: ProjectMergeRequestStatusCheckManager
+
+    @cli.register_custom_action(cls_names="ProjectMergeRequest")
+    @exc.on_http_error(exc.GitlabMROnBuildSuccessError)
+    def cancel_merge_when_pipeline_succeeds(self, **kwargs: Any) -> dict[str, str]:
+        """Cancel merge when the pipeline succeeds.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabMROnBuildSuccessError: If the server could not handle the
+                request
+
+        Returns:
+            dict of the parsed json returned by the server
+        """
+
+        path = (
+            f"{self.manager.path}/{self.encoded_id}/cancel_merge_when_pipeline_succeeds"
+        )
+        server_data = self.manager.gitlab.http_post(path, **kwargs)
+        # 2022-10-30: The docs at
+        # https://docs.gitlab.com/ee/api/merge_requests.html#cancel-merge-when-pipeline-succeeds
+        # are incorrect in that the return value is actually just:
+        #   {'status': 'success'}  for a successful cancel.
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+        return server_data
+
+    @cli.register_custom_action(cls_names="ProjectMergeRequest")
+    @exc.on_http_error(exc.GitlabListError)
+    def related_issues(self, **kwargs: Any) -> RESTObjectList[ProjectIssue]:
+        """List issues related to this merge request."
+
+        Args:
+            get_all: If True, return all the items, without pagination
+            per_page: Number of items to retrieve per request
+            page: ID of the page to return (starts with page 1)
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabListError: If the list could not be retrieved
+
+        Returns:
+            List of issues
+        """
+
+        path = f"{self.manager.path}/{self.encoded_id}/related_issues"
+        data_list = self.manager.gitlab.http_list(path, iterator=True, **kwargs)
+
+        if TYPE_CHECKING:
+            assert isinstance(data_list, gitlab.GitlabList)
+
+        manager = ProjectIssueManager(self.manager.gitlab, parent=self.manager._parent)
+
+        return RESTObjectList(manager, ProjectIssue, data_list)
+
+    @cli.register_custom_action(cls_names="ProjectMergeRequest")
+    @exc.on_http_error(exc.GitlabListError)
+    def closes_issues(self, **kwargs: Any) -> RESTObjectList[ProjectIssue]:
+        """List issues that will close on merge."
+
+        Args:
+            get_all: If True, return all the items, without pagination
+            per_page: Number of items to retrieve per request
+            page: ID of the page to return (starts with page 1)
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabListError: If the list could not be retrieved
+
+        Returns:
+            List of issues
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/closes_issues"
+        data_list = self.manager.gitlab.http_list(path, iterator=True, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(data_list, gitlab.GitlabList)
+        manager = ProjectIssueManager(self.manager.gitlab, parent=self.manager._parent)
+        return RESTObjectList(manager, ProjectIssue, data_list)
+
+    @cli.register_custom_action(cls_names="ProjectMergeRequest")
+    @exc.on_http_error(exc.GitlabListError)
+    def commits(self, **kwargs: Any) -> RESTObjectList[ProjectCommit]:
+        """List the merge request commits.
+
+        Args:
+            get_all: If True, return all the items, without pagination
+            per_page: Number of items to retrieve per request
+            page: ID of the page to return (starts with page 1)
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabListError: If the list could not be retrieved
+
+        Returns:
+            The list of commits
+        """
+
+        path = f"{self.manager.path}/{self.encoded_id}/commits"
+        data_list = self.manager.gitlab.http_list(path, iterator=True, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(data_list, gitlab.GitlabList)
+        manager = ProjectCommitManager(self.manager.gitlab, parent=self.manager._parent)
+        return RESTObjectList(manager, ProjectCommit, data_list)
+
+    @cli.register_custom_action(
+        cls_names="ProjectMergeRequest", optional=("access_raw_diffs",)
+    )
+    @exc.on_http_error(exc.GitlabListError)
+    def changes(self, **kwargs: Any) -> dict[str, Any] | requests.Response:
+        """List the merge request changes.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabListError: If the list could not be retrieved
+
+        Returns:
+            List of changes
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/changes"
+        return self.manager.gitlab.http_get(path, **kwargs)
+
+    @cli.register_custom_action(cls_names="ProjectMergeRequest", optional=("sha",))
+    @exc.on_http_error(exc.GitlabMRApprovalError)
+    def approve(self, sha: str | None = None, **kwargs: Any) -> dict[str, Any]:
+        """Approve the merge request.
+
+        Args:
+            sha: Head SHA of MR
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabMRApprovalError: If the approval failed
+
+        Returns:
+           A dict containing the result.
+
+        https://docs.gitlab.com/ee/api/merge_request_approvals.html#approve-merge-request
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/approve"
+        data = {}
+        if sha:
+            data["sha"] = sha
+
+        server_data = self.manager.gitlab.http_post(path, post_data=data, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+        self._update_attrs(server_data)
+        return server_data
+
+    @cli.register_custom_action(cls_names="ProjectMergeRequest")
+    @exc.on_http_error(exc.GitlabMRApprovalError)
+    def unapprove(self, **kwargs: Any) -> None:
+        """Unapprove the merge request.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabMRApprovalError: If the unapproval failed
+
+        https://docs.gitlab.com/ee/api/merge_request_approvals.html#unapprove-merge-request
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/unapprove"
+        data: dict[str, Any] = {}
+
+        server_data = self.manager.gitlab.http_post(path, post_data=data, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+        self._update_attrs(server_data)
+
+    @cli.register_custom_action(cls_names="ProjectMergeRequest")
+    @exc.on_http_error(exc.GitlabMRRebaseError)
+    def rebase(self, **kwargs: Any) -> dict[str, Any] | requests.Response:
+        """Attempt to rebase the source branch onto the target branch
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabMRRebaseError: If rebasing failed
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/rebase"
+        data: dict[str, Any] = {}
+        return self.manager.gitlab.http_put(path, post_data=data, **kwargs)
+
+    @cli.register_custom_action(cls_names="ProjectMergeRequest")
+    @exc.on_http_error(exc.GitlabMRResetApprovalError)
+    def reset_approvals(self, **kwargs: Any) -> dict[str, Any] | requests.Response:
+        """Clear all approvals of the merge request.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabMRResetApprovalError: If reset approval failed
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/reset_approvals"
+        data: dict[str, Any] = {}
+        return self.manager.gitlab.http_put(path, post_data=data, **kwargs)
+
+    @cli.register_custom_action(cls_names="ProjectMergeRequest")
+    @exc.on_http_error(exc.GitlabGetError)
+    def merge_ref(self, **kwargs: Any) -> dict[str, Any] | requests.Response:
+        """Attempt to merge changes between source and target branches into
+            `refs/merge-requests/:iid/merge`.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabGetError: If cannot be merged
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/merge_ref"
+        return self.manager.gitlab.http_get(path, **kwargs)
+
+    @cli.register_custom_action(
+        cls_names="ProjectMergeRequest",
+        optional=(
+            "merge_commit_message",
+            "should_remove_source_branch",
+            "merge_when_pipeline_succeeds",
+        ),
+    )
+    @exc.on_http_error(exc.GitlabMRClosedError)
+    def merge(
+        self,
+        merge_commit_message: str | None = None,
+        should_remove_source_branch: bool | None = None,
+        merge_when_pipeline_succeeds: bool | None = None,
+        **kwargs: Any,
+    ) -> dict[str, Any]:
+        """Accept the merge request.
+
+        Args:
+            merge_commit_message: Commit message
+            should_remove_source_branch: If True, removes the source
+                                                branch
+            merge_when_pipeline_succeeds: Wait for the build to succeed,
+                                                 then merge
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabMRClosedError: If the merge failed
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/merge"
+        data: dict[str, Any] = {}
+        if merge_commit_message:
+            data["merge_commit_message"] = merge_commit_message
+        if should_remove_source_branch is not None:
+            data["should_remove_source_branch"] = should_remove_source_branch
+        if merge_when_pipeline_succeeds is not None:
+            data["merge_when_pipeline_succeeds"] = merge_when_pipeline_succeeds
+
+        server_data = self.manager.gitlab.http_put(path, post_data=data, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+        self._update_attrs(server_data)
+        return server_data
+
+
+class ProjectMergeRequestManager(CRUDMixin[ProjectMergeRequest]):
+    _path = "/projects/{project_id}/merge_requests"
+    _obj_cls = ProjectMergeRequest
+    _from_parent_attrs = {"project_id": "id"}
+    _optional_get_attrs = (
+        "render_html",
+        "include_diverged_commits_count",
+        "include_rebase_in_progress",
+    )
+    _create_attrs = RequiredOptional(
+        required=("source_branch", "target_branch", "title"),
+        optional=(
+            "allow_collaboration",
+            "allow_maintainer_to_push",
+            "approvals_before_merge",
+            "assignee_id",
+            "assignee_ids",
+            "description",
+            "labels",
+            "milestone_id",
+            "remove_source_branch",
+            "reviewer_ids",
+            "squash",
+            "target_project_id",
+        ),
+    )
+    _update_attrs = RequiredOptional(
+        optional=(
+            "target_branch",
+            "assignee_id",
+            "title",
+            "description",
+            "state_event",
+            "labels",
+            "milestone_id",
+            "remove_source_branch",
+            "discussion_locked",
+            "allow_maintainer_to_push",
+            "squash",
+            "reviewer_ids",
+        )
+    )
+    _list_filters = (
+        "state",
+        "order_by",
+        "sort",
+        "milestone",
+        "view",
+        "labels",
+        "created_after",
+        "created_before",
+        "updated_after",
+        "updated_before",
+        "scope",
+        "iids",
+        "author_id",
+        "assignee_id",
+        "approver_ids",
+        "approved_by_ids",
+        "my_reaction_emoji",
+        "source_branch",
+        "target_branch",
+        "search",
+        "wip",
+    )
+    _types = {
+        "approver_ids": types.ArrayAttribute,
+        "approved_by_ids": types.ArrayAttribute,
+        "iids": types.ArrayAttribute,
+        "labels": types.CommaSeparatedListAttribute,
+    }
+
+
+class ProjectDeploymentMergeRequest(MergeRequest):
+    pass
+
+
+class ProjectDeploymentMergeRequestManager(MergeRequestManager):
+    _path = "/projects/{project_id}/deployments/{deployment_id}/merge_requests"
+    _obj_cls = ProjectDeploymentMergeRequest
+    _from_parent_attrs = {"deployment_id": "id", "project_id": "project_id"}
+
+
+class ProjectMergeRequestDiff(RESTObject):
+    pass
+
+
+class ProjectMergeRequestDiffManager(RetrieveMixin[ProjectMergeRequestDiff]):
+    _path = "/projects/{project_id}/merge_requests/{mr_iid}/versions"
+    _obj_cls = ProjectMergeRequestDiff
+    _from_parent_attrs = {"project_id": "project_id", "mr_iid": "iid"}
diff --git a/gitlab/v4/objects/merge_trains.py b/gitlab/v4/objects/merge_trains.py
new file mode 100644
index 000000000..a1c5a447d
--- /dev/null
+++ b/gitlab/v4/objects/merge_trains.py
@@ -0,0 +1,15 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import ListMixin
+
+__all__ = ["ProjectMergeTrain", "ProjectMergeTrainManager"]
+
+
+class ProjectMergeTrain(RESTObject):
+    pass
+
+
+class ProjectMergeTrainManager(ListMixin[ProjectMergeTrain]):
+    _path = "/projects/{project_id}/merge_trains"
+    _obj_cls = ProjectMergeTrain
+    _from_parent_attrs = {"project_id": "id"}
+    _list_filters = ("scope",)
diff --git a/gitlab/v4/objects/milestones.py b/gitlab/v4/objects/milestones.py
new file mode 100644
index 000000000..9a485035e
--- /dev/null
+++ b/gitlab/v4/objects/milestones.py
@@ -0,0 +1,178 @@
+from typing import Any, TYPE_CHECKING
+
+from gitlab import cli
+from gitlab import exceptions as exc
+from gitlab import types
+from gitlab.base import RESTObject, RESTObjectList
+from gitlab.client import GitlabList
+from gitlab.mixins import (
+    CRUDMixin,
+    ObjectDeleteMixin,
+    PromoteMixin,
+    SaveMixin,
+    UpdateMethod,
+)
+from gitlab.types import RequiredOptional
+
+from .issues import GroupIssue, GroupIssueManager, ProjectIssue, ProjectIssueManager
+from .merge_requests import (
+    GroupMergeRequest,
+    GroupMergeRequestManager,
+    ProjectMergeRequest,
+    ProjectMergeRequestManager,
+)
+
+__all__ = [
+    "GroupMilestone",
+    "GroupMilestoneManager",
+    "ProjectMilestone",
+    "ProjectMilestoneManager",
+]
+
+
+class GroupMilestone(SaveMixin, ObjectDeleteMixin, RESTObject):
+    _repr_attr = "title"
+
+    @cli.register_custom_action(cls_names="GroupMilestone")
+    @exc.on_http_error(exc.GitlabListError)
+    def issues(self, **kwargs: Any) -> RESTObjectList[GroupIssue]:
+        """List issues related to this milestone.
+
+        Args:
+            get_all: If True, return all the items, without pagination
+            per_page: Number of items to retrieve per request
+            page: ID of the page to return (starts with page 1)
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabListError: If the list could not be retrieved
+
+        Returns:
+            The list of issues
+        """
+
+        path = f"{self.manager.path}/{self.encoded_id}/issues"
+        data_list = self.manager.gitlab.http_list(path, iterator=True, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(data_list, GitlabList)
+        manager = GroupIssueManager(self.manager.gitlab, parent=self.manager._parent)
+        # FIXME(gpocentek): the computed manager path is not correct
+        return RESTObjectList(manager, GroupIssue, data_list)
+
+    @cli.register_custom_action(cls_names="GroupMilestone")
+    @exc.on_http_error(exc.GitlabListError)
+    def merge_requests(self, **kwargs: Any) -> RESTObjectList[GroupMergeRequest]:
+        """List the merge requests related to this milestone.
+
+        Args:
+            get_all: If True, return all the items, without pagination
+            per_page: Number of items to retrieve per request
+            page: ID of the page to return (starts with page 1)
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabListError: If the list could not be retrieved
+
+        Returns:
+            The list of merge requests
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/merge_requests"
+        data_list = self.manager.gitlab.http_list(path, iterator=True, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(data_list, GitlabList)
+        manager = GroupMergeRequestManager(
+            self.manager.gitlab, parent=self.manager._parent
+        )
+        # FIXME(gpocentek): the computed manager path is not correct
+        return RESTObjectList(manager, GroupMergeRequest, data_list)
+
+
+class GroupMilestoneManager(CRUDMixin[GroupMilestone]):
+    _path = "/groups/{group_id}/milestones"
+    _obj_cls = GroupMilestone
+    _from_parent_attrs = {"group_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("title",), optional=("description", "due_date", "start_date")
+    )
+    _update_attrs = RequiredOptional(
+        optional=("title", "description", "due_date", "start_date", "state_event")
+    )
+    _list_filters = ("iids", "state", "search")
+    _types = {"iids": types.ArrayAttribute}
+
+
+class ProjectMilestone(PromoteMixin, SaveMixin, ObjectDeleteMixin, RESTObject):
+    _repr_attr = "title"
+    _update_method = UpdateMethod.POST
+
+    @cli.register_custom_action(cls_names="ProjectMilestone")
+    @exc.on_http_error(exc.GitlabListError)
+    def issues(self, **kwargs: Any) -> RESTObjectList[ProjectIssue]:
+        """List issues related to this milestone.
+
+        Args:
+            get_all: If True, return all the items, without pagination
+            per_page: Number of items to retrieve per request
+            page: ID of the page to return (starts with page 1)
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabListError: If the list could not be retrieved
+
+        Returns:
+            The list of issues
+        """
+
+        path = f"{self.manager.path}/{self.encoded_id}/issues"
+        data_list = self.manager.gitlab.http_list(path, iterator=True, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(data_list, GitlabList)
+        manager = ProjectIssueManager(self.manager.gitlab, parent=self.manager._parent)
+        # FIXME(gpocentek): the computed manager path is not correct
+        return RESTObjectList(manager, ProjectIssue, data_list)
+
+    @cli.register_custom_action(cls_names="ProjectMilestone")
+    @exc.on_http_error(exc.GitlabListError)
+    def merge_requests(self, **kwargs: Any) -> RESTObjectList[ProjectMergeRequest]:
+        """List the merge requests related to this milestone.
+
+        Args:
+            get_all: If True, return all the items, without pagination
+            per_page: Number of items to retrieve per request
+            page: ID of the page to return (starts with page 1)
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabListError: If the list could not be retrieved
+
+        Returns:
+            The list of merge requests
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/merge_requests"
+        data_list = self.manager.gitlab.http_list(path, iterator=True, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(data_list, GitlabList)
+        manager = ProjectMergeRequestManager(
+            self.manager.gitlab, parent=self.manager._parent
+        )
+        # FIXME(gpocentek): the computed manager path is not correct
+        return RESTObjectList(manager, ProjectMergeRequest, data_list)
+
+
+class ProjectMilestoneManager(CRUDMixin[ProjectMilestone]):
+    _path = "/projects/{project_id}/milestones"
+    _obj_cls = ProjectMilestone
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("title",),
+        optional=("description", "due_date", "start_date", "state_event"),
+    )
+    _update_attrs = RequiredOptional(
+        optional=("title", "description", "due_date", "start_date", "state_event")
+    )
+    _list_filters = ("iids", "state", "search")
+    _types = {"iids": types.ArrayAttribute}
diff --git a/gitlab/v4/objects/namespaces.py b/gitlab/v4/objects/namespaces.py
new file mode 100644
index 000000000..25000800f
--- /dev/null
+++ b/gitlab/v4/objects/namespaces.py
@@ -0,0 +1,43 @@
+from typing import Any, TYPE_CHECKING
+
+from gitlab import cli
+from gitlab import exceptions as exc
+from gitlab.base import RESTObject
+from gitlab.mixins import RetrieveMixin
+from gitlab.utils import EncodedId
+
+__all__ = ["Namespace", "NamespaceManager"]
+
+
+class Namespace(RESTObject):
+    pass
+
+
+class NamespaceManager(RetrieveMixin[Namespace]):
+    _path = "/namespaces"
+    _obj_cls = Namespace
+    _list_filters = ("search",)
+
+    @cli.register_custom_action(
+        cls_names="NamespaceManager", required=("namespace", "parent_id")
+    )
+    @exc.on_http_error(exc.GitlabGetError)
+    def exists(self, namespace: str, **kwargs: Any) -> Namespace:
+        """Get existence of a namespace by path.
+
+        Args:
+            namespace: The path to the namespace.
+            **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:
+            Data on namespace existence returned from the server.
+        """
+        path = f"{self.path}/{EncodedId(namespace)}/exists"
+        server_data = self.gitlab.http_get(path, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+        return self._obj_cls(self, server_data)
diff --git a/gitlab/v4/objects/notes.py b/gitlab/v4/objects/notes.py
new file mode 100644
index 000000000..3e83d9be1
--- /dev/null
+++ b/gitlab/v4/objects/notes.py
@@ -0,0 +1,211 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    CreateMixin,
+    CRUDMixin,
+    DeleteMixin,
+    GetMixin,
+    ObjectDeleteMixin,
+    RetrieveMixin,
+    SaveMixin,
+    UpdateMixin,
+)
+from gitlab.types import RequiredOptional
+
+from .award_emojis import (  # noqa: F401
+    GroupEpicNoteAwardEmojiManager,
+    ProjectIssueNoteAwardEmojiManager,
+    ProjectMergeRequestNoteAwardEmojiManager,
+    ProjectSnippetNoteAwardEmojiManager,
+)
+
+__all__ = [
+    "GroupEpicNote",
+    "GroupEpicNoteManager",
+    "GroupEpicDiscussionNote",
+    "GroupEpicDiscussionNoteManager",
+    "ProjectNote",
+    "ProjectNoteManager",
+    "ProjectCommitDiscussionNote",
+    "ProjectCommitDiscussionNoteManager",
+    "ProjectIssueNote",
+    "ProjectIssueNoteManager",
+    "ProjectIssueDiscussionNote",
+    "ProjectIssueDiscussionNoteManager",
+    "ProjectMergeRequestNote",
+    "ProjectMergeRequestNoteManager",
+    "ProjectMergeRequestDiscussionNote",
+    "ProjectMergeRequestDiscussionNoteManager",
+    "ProjectSnippetNote",
+    "ProjectSnippetNoteManager",
+    "ProjectSnippetDiscussionNote",
+    "ProjectSnippetDiscussionNoteManager",
+]
+
+
+class GroupEpicNote(SaveMixin, ObjectDeleteMixin, RESTObject):
+    awardemojis: GroupEpicNoteAwardEmojiManager
+
+
+class GroupEpicNoteManager(CRUDMixin[GroupEpicNote]):
+    _path = "/groups/{group_id}/epics/{epic_id}/notes"
+    _obj_cls = GroupEpicNote
+    _from_parent_attrs = {"group_id": "group_id", "epic_id": "id"}
+    _create_attrs = RequiredOptional(required=("body",), optional=("created_at",))
+    _update_attrs = RequiredOptional(required=("body",))
+
+
+class GroupEpicDiscussionNote(SaveMixin, ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class GroupEpicDiscussionNoteManager(
+    GetMixin[GroupEpicDiscussionNote],
+    CreateMixin[GroupEpicDiscussionNote],
+    UpdateMixin[GroupEpicDiscussionNote],
+    DeleteMixin[GroupEpicDiscussionNote],
+):
+    _path = "/groups/{group_id}/epics/{epic_id}/discussions/{discussion_id}/notes"
+    _obj_cls = GroupEpicDiscussionNote
+    _from_parent_attrs = {
+        "group_id": "group_id",
+        "epic_id": "epic_id",
+        "discussion_id": "id",
+    }
+    _create_attrs = RequiredOptional(required=("body",), optional=("created_at",))
+    _update_attrs = RequiredOptional(required=("body",))
+
+
+class ProjectNote(RESTObject):
+    pass
+
+
+class ProjectNoteManager(RetrieveMixin[ProjectNote]):
+    _path = "/projects/{project_id}/notes"
+    _obj_cls = ProjectNote
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(required=("body",))
+
+
+class ProjectCommitDiscussionNote(SaveMixin, ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class ProjectCommitDiscussionNoteManager(
+    GetMixin[ProjectCommitDiscussionNote],
+    CreateMixin[ProjectCommitDiscussionNote],
+    UpdateMixin[ProjectCommitDiscussionNote],
+    DeleteMixin[ProjectCommitDiscussionNote],
+):
+    _path = (
+        "/projects/{project_id}/repository/commits/{commit_id}/"
+        "discussions/{discussion_id}/notes"
+    )
+    _obj_cls = ProjectCommitDiscussionNote
+    _from_parent_attrs = {
+        "project_id": "project_id",
+        "commit_id": "commit_id",
+        "discussion_id": "id",
+    }
+    _create_attrs = RequiredOptional(
+        required=("body",), optional=("created_at", "position")
+    )
+    _update_attrs = RequiredOptional(required=("body",))
+
+
+class ProjectIssueNote(SaveMixin, ObjectDeleteMixin, RESTObject):
+    awardemojis: ProjectIssueNoteAwardEmojiManager
+
+
+class ProjectIssueNoteManager(CRUDMixin[ProjectIssueNote]):
+    _path = "/projects/{project_id}/issues/{issue_iid}/notes"
+    _obj_cls = ProjectIssueNote
+    _from_parent_attrs = {"project_id": "project_id", "issue_iid": "iid"}
+    _create_attrs = RequiredOptional(required=("body",), optional=("created_at",))
+    _update_attrs = RequiredOptional(required=("body",))
+
+
+class ProjectIssueDiscussionNote(SaveMixin, ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class ProjectIssueDiscussionNoteManager(CRUDMixin[ProjectIssueDiscussionNote]):
+    _path = (
+        "/projects/{project_id}/issues/{issue_iid}/discussions/{discussion_id}/notes"
+    )
+    _obj_cls = ProjectIssueDiscussionNote
+    _from_parent_attrs = {
+        "project_id": "project_id",
+        "issue_iid": "issue_iid",
+        "discussion_id": "id",
+    }
+    _create_attrs = RequiredOptional(required=("body",), optional=("created_at",))
+    _update_attrs = RequiredOptional(required=("body",))
+
+
+class ProjectMergeRequestNote(SaveMixin, ObjectDeleteMixin, RESTObject):
+    awardemojis: ProjectMergeRequestNoteAwardEmojiManager
+
+
+class ProjectMergeRequestNoteManager(CRUDMixin[ProjectMergeRequestNote]):
+    _path = "/projects/{project_id}/merge_requests/{mr_iid}/notes"
+    _obj_cls = ProjectMergeRequestNote
+    _from_parent_attrs = {"project_id": "project_id", "mr_iid": "iid"}
+    _create_attrs = RequiredOptional(required=("body",))
+    _update_attrs = RequiredOptional(required=("body",))
+
+
+class ProjectMergeRequestDiscussionNote(SaveMixin, ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class ProjectMergeRequestDiscussionNoteManager(
+    CRUDMixin[ProjectMergeRequestDiscussionNote]
+):
+    _path = (
+        "/projects/{project_id}/merge_requests/{mr_iid}/"
+        "discussions/{discussion_id}/notes"
+    )
+    _obj_cls = ProjectMergeRequestDiscussionNote
+    _from_parent_attrs = {
+        "project_id": "project_id",
+        "mr_iid": "mr_iid",
+        "discussion_id": "id",
+    }
+    _create_attrs = RequiredOptional(required=("body",), optional=("created_at",))
+    _update_attrs = RequiredOptional(required=("body",))
+
+
+class ProjectSnippetNote(SaveMixin, ObjectDeleteMixin, RESTObject):
+    awardemojis: ProjectSnippetNoteAwardEmojiManager
+
+
+class ProjectSnippetNoteManager(CRUDMixin[ProjectSnippetNote]):
+    _path = "/projects/{project_id}/snippets/{snippet_id}/notes"
+    _obj_cls = ProjectSnippetNote
+    _from_parent_attrs = {"project_id": "project_id", "snippet_id": "id"}
+    _create_attrs = RequiredOptional(required=("body",))
+    _update_attrs = RequiredOptional(required=("body",))
+
+
+class ProjectSnippetDiscussionNote(SaveMixin, ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class ProjectSnippetDiscussionNoteManager(
+    GetMixin[ProjectSnippetDiscussionNote],
+    CreateMixin[ProjectSnippetDiscussionNote],
+    UpdateMixin[ProjectSnippetDiscussionNote],
+    DeleteMixin[ProjectSnippetDiscussionNote],
+):
+    _path = (
+        "/projects/{project_id}/snippets/{snippet_id}/"
+        "discussions/{discussion_id}/notes"
+    )
+    _obj_cls = ProjectSnippetDiscussionNote
+    _from_parent_attrs = {
+        "project_id": "project_id",
+        "snippet_id": "snippet_id",
+        "discussion_id": "id",
+    }
+    _create_attrs = RequiredOptional(required=("body",), optional=("created_at",))
+    _update_attrs = RequiredOptional(required=("body",))
diff --git a/gitlab/v4/objects/notification_settings.py b/gitlab/v4/objects/notification_settings.py
new file mode 100644
index 000000000..ed07d2b9a
--- /dev/null
+++ b/gitlab/v4/objects/notification_settings.py
@@ -0,0 +1,60 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import GetWithoutIdMixin, SaveMixin, UpdateMixin
+from gitlab.types import RequiredOptional
+
+__all__ = [
+    "NotificationSettings",
+    "NotificationSettingsManager",
+    "GroupNotificationSettings",
+    "GroupNotificationSettingsManager",
+    "ProjectNotificationSettings",
+    "ProjectNotificationSettingsManager",
+]
+
+
+class NotificationSettings(SaveMixin, RESTObject):
+    _id_attr = None
+
+
+class NotificationSettingsManager(
+    GetWithoutIdMixin[NotificationSettings], UpdateMixin[NotificationSettings]
+):
+    _path = "/notification_settings"
+    _obj_cls = NotificationSettings
+
+    _update_attrs = RequiredOptional(
+        optional=(
+            "level",
+            "notification_email",
+            "new_note",
+            "new_issue",
+            "reopen_issue",
+            "close_issue",
+            "reassign_issue",
+            "new_merge_request",
+            "reopen_merge_request",
+            "close_merge_request",
+            "reassign_merge_request",
+            "merge_merge_request",
+        )
+    )
+
+
+class GroupNotificationSettings(NotificationSettings):
+    pass
+
+
+class GroupNotificationSettingsManager(NotificationSettingsManager):
+    _path = "/groups/{group_id}/notification_settings"
+    _obj_cls = GroupNotificationSettings
+    _from_parent_attrs = {"group_id": "id"}
+
+
+class ProjectNotificationSettings(NotificationSettings):
+    pass
+
+
+class ProjectNotificationSettingsManager(NotificationSettingsManager):
+    _path = "/projects/{project_id}/notification_settings"
+    _obj_cls = ProjectNotificationSettings
+    _from_parent_attrs = {"project_id": "id"}
diff --git a/gitlab/v4/objects/package_protection_rules.py b/gitlab/v4/objects/package_protection_rules.py
new file mode 100644
index 000000000..64feb2784
--- /dev/null
+++ b/gitlab/v4/objects/package_protection_rules.py
@@ -0,0 +1,43 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    CreateMixin,
+    DeleteMixin,
+    ListMixin,
+    ObjectDeleteMixin,
+    SaveMixin,
+    UpdateMethod,
+    UpdateMixin,
+)
+from gitlab.types import RequiredOptional
+
+__all__ = ["ProjectPackageProtectionRule", "ProjectPackageProtectionRuleManager"]
+
+
+class ProjectPackageProtectionRule(ObjectDeleteMixin, SaveMixin, RESTObject):
+    _repr_attr = "package_name_pattern"
+
+
+class ProjectPackageProtectionRuleManager(
+    ListMixin[ProjectPackageProtectionRule],
+    CreateMixin[ProjectPackageProtectionRule],
+    DeleteMixin[ProjectPackageProtectionRule],
+    UpdateMixin[ProjectPackageProtectionRule],
+):
+    _path = "/projects/{project_id}/packages/protection/rules"
+    _obj_cls = ProjectPackageProtectionRule
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=(
+            "package_name_pattern",
+            "package_type",
+            "minimum_access_level_for_push",
+        )
+    )
+    _update_attrs = RequiredOptional(
+        optional=(
+            "package_name_pattern",
+            "package_type",
+            "minimum_access_level_for_push",
+        )
+    )
+    _update_method = UpdateMethod.PATCH
diff --git a/gitlab/v4/objects/packages.py b/gitlab/v4/objects/packages.py
new file mode 100644
index 000000000..1a59c7ec7
--- /dev/null
+++ b/gitlab/v4/objects/packages.py
@@ -0,0 +1,259 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/packages.html
+https://docs.gitlab.com/ee/user/packages/generic_packages/
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any, BinaryIO, Callable, Iterator, Literal, overload, TYPE_CHECKING
+
+import requests
+
+from gitlab import cli
+from gitlab import exceptions as exc
+from gitlab import utils
+from gitlab.base import RESTManager, RESTObject
+from gitlab.mixins import DeleteMixin, GetMixin, ListMixin, ObjectDeleteMixin
+
+__all__ = [
+    "GenericPackage",
+    "GenericPackageManager",
+    "GroupPackage",
+    "GroupPackageManager",
+    "ProjectPackage",
+    "ProjectPackageManager",
+    "ProjectPackageFile",
+    "ProjectPackageFileManager",
+    "ProjectPackagePipeline",
+    "ProjectPackagePipelineManager",
+]
+
+
+class GenericPackage(RESTObject):
+    _id_attr = "package_name"
+
+
+class GenericPackageManager(RESTManager[GenericPackage]):
+    _path = "/projects/{project_id}/packages/generic"
+    _obj_cls = GenericPackage
+    _from_parent_attrs = {"project_id": "id"}
+
+    @cli.register_custom_action(
+        cls_names="GenericPackageManager",
+        required=("package_name", "package_version", "file_name", "path"),
+    )
+    @exc.on_http_error(exc.GitlabUploadError)
+    def upload(
+        self,
+        package_name: str,
+        package_version: str,
+        file_name: str,
+        path: str | Path | None = None,
+        select: str | None = None,
+        data: bytes | BinaryIO | None = None,
+        **kwargs: Any,
+    ) -> GenericPackage:
+        """Upload a file as a generic package.
+
+        Args:
+            package_name: The package name. Must follow generic package
+                                name regex rules
+            package_version: The package version. Must follow semantic
+                                version regex rules
+            file_name: The name of the file as uploaded in the registry
+            path: The path to a local file to upload
+            select: GitLab API accepts a value of 'package_file'
+
+        Raises:
+            GitlabConnectionError: If the server cannot be reached
+            GitlabUploadError: If the file upload fails
+            GitlabUploadError: If ``path`` cannot be read
+            GitlabUploadError: If both ``path`` and ``data`` are passed
+
+        Returns:
+            An object storing the metadata of the uploaded package.
+
+        https://docs.gitlab.com/ee/user/packages/generic_packages/
+        """
+
+        if path is None and data is None:
+            raise exc.GitlabUploadError("No file contents or path specified")
+
+        if path is not None and data is not None:
+            raise exc.GitlabUploadError("File contents and file path specified")
+
+        file_data: bytes | BinaryIO | None = data
+
+        if not file_data:
+            if TYPE_CHECKING:
+                assert path is not None
+
+            try:
+                with open(path, "rb") as f:
+                    file_data = f.read()
+            except OSError as e:
+                raise exc.GitlabUploadError(
+                    f"Failed to read package file {path}"
+                ) from e
+
+        url = f"{self._computed_path}/{package_name}/{package_version}/{file_name}"
+        query_data = {} if select is None else {"select": select}
+        server_data = self.gitlab.http_put(
+            url, query_data=query_data, post_data=file_data, raw=True, **kwargs
+        )
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+
+        attrs = {
+            "package_name": package_name,
+            "package_version": package_version,
+            "file_name": file_name,
+            "path": path,
+        }
+        attrs.update(server_data)
+        return self._obj_cls(self, attrs=attrs)
+
+    @overload
+    def download(
+        self,
+        package_name: str,
+        package_version: str,
+        file_name: str,
+        streamed: Literal[False] = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> bytes: ...
+
+    @overload
+    def download(
+        self,
+        package_name: str,
+        package_version: str,
+        file_name: str,
+        streamed: bool = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[True] = True,
+        **kwargs: Any,
+    ) -> Iterator[Any]: ...
+
+    @overload
+    def download(
+        self,
+        package_name: str,
+        package_version: str,
+        file_name: str,
+        streamed: Literal[True] = True,
+        action: Callable[[bytes], Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> None: ...
+
+    @cli.register_custom_action(
+        cls_names="GenericPackageManager",
+        required=("package_name", "package_version", "file_name"),
+    )
+    @exc.on_http_error(exc.GitlabGetError)
+    def download(
+        self,
+        package_name: str,
+        package_version: str,
+        file_name: str,
+        streamed: bool = False,
+        action: Callable[[bytes], Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: bool = False,
+        **kwargs: Any,
+    ) -> bytes | Iterator[Any] | None:
+        """Download a generic package.
+
+        Args:
+            package_name: The package name.
+            package_version: The package version.
+            file_name: The name of the file in the registry
+            streamed: If True the data will be processed by chunks of
+                `chunk_size` and each chunk is passed to `action` for
+                treatment
+            iterator: If True directly return the underlying response
+                iterator
+            action: Callable responsible of dealing with chunk of
+                data
+            chunk_size: 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:
+            The package content if streamed is False, None otherwise
+        """
+        path = f"{self._computed_path}/{package_name}/{package_version}/{file_name}"
+        result = self.gitlab.http_get(path, streamed=streamed, raw=True, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(result, requests.Response)
+        return utils.response_content(
+            result, streamed, action, chunk_size, iterator=iterator
+        )
+
+
+class GroupPackage(RESTObject):
+    pass
+
+
+class GroupPackageManager(ListMixin[GroupPackage]):
+    _path = "/groups/{group_id}/packages"
+    _obj_cls = GroupPackage
+    _from_parent_attrs = {"group_id": "id"}
+    _list_filters = (
+        "exclude_subgroups",
+        "order_by",
+        "sort",
+        "package_type",
+        "package_name",
+    )
+
+
+class ProjectPackage(ObjectDeleteMixin, RESTObject):
+    package_files: ProjectPackageFileManager
+    pipelines: ProjectPackagePipelineManager
+
+
+class ProjectPackageManager(
+    ListMixin[ProjectPackage], GetMixin[ProjectPackage], DeleteMixin[ProjectPackage]
+):
+    _path = "/projects/{project_id}/packages"
+    _obj_cls = ProjectPackage
+    _from_parent_attrs = {"project_id": "id"}
+    _list_filters = ("order_by", "sort", "package_type", "package_name")
+
+
+class ProjectPackageFile(ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class ProjectPackageFileManager(
+    DeleteMixin[ProjectPackageFile], ListMixin[ProjectPackageFile]
+):
+    _path = "/projects/{project_id}/packages/{package_id}/package_files"
+    _obj_cls = ProjectPackageFile
+    _from_parent_attrs = {"project_id": "project_id", "package_id": "id"}
+
+
+class ProjectPackagePipeline(RESTObject):
+    pass
+
+
+class ProjectPackagePipelineManager(ListMixin[ProjectPackagePipeline]):
+    _path = "/projects/{project_id}/packages/{package_id}/pipelines"
+    _obj_cls = ProjectPackagePipeline
+    _from_parent_attrs = {"project_id": "project_id", "package_id": "id"}
diff --git a/gitlab/v4/objects/pages.py b/gitlab/v4/objects/pages.py
new file mode 100644
index 000000000..ae0b1f43a
--- /dev/null
+++ b/gitlab/v4/objects/pages.py
@@ -0,0 +1,63 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    CRUDMixin,
+    DeleteMixin,
+    GetWithoutIdMixin,
+    ListMixin,
+    ObjectDeleteMixin,
+    RefreshMixin,
+    SaveMixin,
+    UpdateMethod,
+    UpdateMixin,
+)
+from gitlab.types import RequiredOptional
+
+__all__ = [
+    "PagesDomain",
+    "PagesDomainManager",
+    "ProjectPagesDomain",
+    "ProjectPagesDomainManager",
+    "ProjectPages",
+    "ProjectPagesManager",
+]
+
+
+class PagesDomain(RESTObject):
+    _id_attr = "domain"
+
+
+class PagesDomainManager(ListMixin[PagesDomain]):
+    _path = "/pages/domains"
+    _obj_cls = PagesDomain
+
+
+class ProjectPagesDomain(SaveMixin, ObjectDeleteMixin, RESTObject):
+    _id_attr = "domain"
+
+
+class ProjectPagesDomainManager(CRUDMixin[ProjectPagesDomain]):
+    _path = "/projects/{project_id}/pages/domains"
+    _obj_cls = ProjectPagesDomain
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("domain",), optional=("certificate", "key")
+    )
+    _update_attrs = RequiredOptional(optional=("certificate", "key"))
+
+
+class ProjectPages(ObjectDeleteMixin, RefreshMixin, RESTObject):
+    _id_attr = None
+
+
+class ProjectPagesManager(
+    DeleteMixin[ProjectPages],
+    UpdateMixin[ProjectPages],
+    GetWithoutIdMixin[ProjectPages],
+):
+    _path = "/projects/{project_id}/pages"
+    _obj_cls = ProjectPages
+    _from_parent_attrs = {"project_id": "id"}
+    _update_attrs = RequiredOptional(
+        optional=("pages_unique_domain_enabled", "pages_https_only")
+    )
+    _update_method: UpdateMethod = UpdateMethod.PATCH
diff --git a/gitlab/v4/objects/personal_access_tokens.py b/gitlab/v4/objects/personal_access_tokens.py
new file mode 100644
index 000000000..ec667499f
--- /dev/null
+++ b/gitlab/v4/objects/personal_access_tokens.py
@@ -0,0 +1,45 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    CreateMixin,
+    DeleteMixin,
+    ObjectDeleteMixin,
+    ObjectRotateMixin,
+    RetrieveMixin,
+    RotateMixin,
+)
+from gitlab.types import ArrayAttribute, RequiredOptional
+
+__all__ = [
+    "PersonalAccessToken",
+    "PersonalAccessTokenManager",
+    "UserPersonalAccessToken",
+    "UserPersonalAccessTokenManager",
+]
+
+
+class PersonalAccessToken(ObjectDeleteMixin, ObjectRotateMixin, RESTObject):
+    pass
+
+
+class PersonalAccessTokenManager(
+    DeleteMixin[PersonalAccessToken],
+    RetrieveMixin[PersonalAccessToken],
+    RotateMixin[PersonalAccessToken],
+):
+    _path = "/personal_access_tokens"
+    _obj_cls = PersonalAccessToken
+    _list_filters = ("user_id",)
+
+
+class UserPersonalAccessToken(RESTObject):
+    pass
+
+
+class UserPersonalAccessTokenManager(CreateMixin[UserPersonalAccessToken]):
+    _path = "/users/{user_id}/personal_access_tokens"
+    _obj_cls = UserPersonalAccessToken
+    _from_parent_attrs = {"user_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("name", "scopes"), optional=("expires_at",)
+    )
+    _types = {"scopes": ArrayAttribute}
diff --git a/gitlab/v4/objects/pipelines.py b/gitlab/v4/objects/pipelines.py
new file mode 100644
index 000000000..7dfd98827
--- /dev/null
+++ b/gitlab/v4/objects/pipelines.py
@@ -0,0 +1,295 @@
+from __future__ import annotations
+
+from typing import Any, TYPE_CHECKING
+
+import requests
+
+from gitlab import cli
+from gitlab import exceptions as exc
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    CreateMixin,
+    CRUDMixin,
+    DeleteMixin,
+    GetWithoutIdMixin,
+    ListMixin,
+    ObjectDeleteMixin,
+    RefreshMixin,
+    RetrieveMixin,
+    SaveMixin,
+    UpdateMixin,
+)
+from gitlab.types import ArrayAttribute, RequiredOptional
+
+__all__ = [
+    "ProjectMergeRequestPipeline",
+    "ProjectMergeRequestPipelineManager",
+    "ProjectPipeline",
+    "ProjectPipelineManager",
+    "ProjectPipelineJob",
+    "ProjectPipelineJobManager",
+    "ProjectPipelineBridge",
+    "ProjectPipelineBridgeManager",
+    "ProjectPipelineVariable",
+    "ProjectPipelineVariableManager",
+    "ProjectPipelineScheduleVariable",
+    "ProjectPipelineScheduleVariableManager",
+    "ProjectPipelineSchedulePipeline",
+    "ProjectPipelineSchedulePipelineManager",
+    "ProjectPipelineSchedule",
+    "ProjectPipelineScheduleManager",
+    "ProjectPipelineTestReport",
+    "ProjectPipelineTestReportManager",
+    "ProjectPipelineTestReportSummary",
+    "ProjectPipelineTestReportSummaryManager",
+]
+
+
+class ProjectMergeRequestPipeline(RESTObject):
+    pass
+
+
+class ProjectMergeRequestPipelineManager(
+    CreateMixin[ProjectMergeRequestPipeline], ListMixin[ProjectMergeRequestPipeline]
+):
+    _path = "/projects/{project_id}/merge_requests/{mr_iid}/pipelines"
+    _obj_cls = ProjectMergeRequestPipeline
+    _from_parent_attrs = {"project_id": "project_id", "mr_iid": "iid"}
+
+
+class ProjectPipeline(RefreshMixin, ObjectDeleteMixin, RESTObject):
+    bridges: ProjectPipelineBridgeManager
+    jobs: ProjectPipelineJobManager
+    test_report: ProjectPipelineTestReportManager
+    test_report_summary: ProjectPipelineTestReportSummaryManager
+    variables: ProjectPipelineVariableManager
+
+    @cli.register_custom_action(cls_names="ProjectPipeline")
+    @exc.on_http_error(exc.GitlabPipelineCancelError)
+    def cancel(self, **kwargs: Any) -> dict[str, Any] | requests.Response:
+        """Cancel the job.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabPipelineCancelError: If the request failed
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/cancel"
+        return self.manager.gitlab.http_post(path, **kwargs)
+
+    @cli.register_custom_action(cls_names="ProjectPipeline")
+    @exc.on_http_error(exc.GitlabPipelineRetryError)
+    def retry(self, **kwargs: Any) -> dict[str, Any] | requests.Response:
+        """Retry the job.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabPipelineRetryError: If the request failed
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/retry"
+        return self.manager.gitlab.http_post(path, **kwargs)
+
+
+class ProjectPipelineManager(
+    RetrieveMixin[ProjectPipeline],
+    CreateMixin[ProjectPipeline],
+    DeleteMixin[ProjectPipeline],
+):
+    _path = "/projects/{project_id}/pipelines"
+    _obj_cls = ProjectPipeline
+    _from_parent_attrs = {"project_id": "id"}
+    _list_filters = (
+        "scope",
+        "status",
+        "source",
+        "ref",
+        "sha",
+        "yaml_errors",
+        "name",
+        "username",
+        "order_by",
+        "sort",
+    )
+    _create_attrs = RequiredOptional(required=("ref",))
+
+    def create(
+        self, data: dict[str, Any] | None = None, **kwargs: Any
+    ) -> ProjectPipeline:
+        """Creates a new object.
+
+        Args:
+            data: Parameters to send to the server to create the
+                         resource
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabCreateError: If the server cannot perform the request
+
+        Returns:
+            A new instance of the managed object class build with
+                the data sent by the server
+        """
+        path = self.path[:-1]  # drop the 's'
+        return super().create(data, path=path, **kwargs)
+
+    def latest(self, ref: str | None = None, lazy: bool = False) -> ProjectPipeline:
+        """Get the latest pipeline for the most recent commit
+                            on a specific ref in a project
+
+        Args:
+            ref: The branch or tag to check for the latest pipeline.
+                            Defaults to the default branch when not specified.
+        Returns:
+            A Pipeline instance
+        """
+        data = {}
+        if ref:
+            data = {"ref": ref}
+        server_data = self.gitlab.http_get(self.path + "/latest", query_data=data)
+        if TYPE_CHECKING:
+            assert not isinstance(server_data, requests.Response)
+        return self._obj_cls(self, server_data, lazy=lazy)
+
+
+class ProjectPipelineJob(RESTObject):
+    pass
+
+
+class ProjectPipelineJobManager(ListMixin[ProjectPipelineJob]):
+    _path = "/projects/{project_id}/pipelines/{pipeline_id}/jobs"
+    _obj_cls = ProjectPipelineJob
+    _from_parent_attrs = {"project_id": "project_id", "pipeline_id": "id"}
+    _list_filters = ("scope", "include_retried")
+    _types = {"scope": ArrayAttribute}
+
+
+class ProjectPipelineBridge(RESTObject):
+    pass
+
+
+class ProjectPipelineBridgeManager(ListMixin[ProjectPipelineBridge]):
+    _path = "/projects/{project_id}/pipelines/{pipeline_id}/bridges"
+    _obj_cls = ProjectPipelineBridge
+    _from_parent_attrs = {"project_id": "project_id", "pipeline_id": "id"}
+    _list_filters = ("scope",)
+
+
+class ProjectPipelineVariable(RESTObject):
+    _id_attr = "key"
+
+
+class ProjectPipelineVariableManager(ListMixin[ProjectPipelineVariable]):
+    _path = "/projects/{project_id}/pipelines/{pipeline_id}/variables"
+    _obj_cls = ProjectPipelineVariable
+    _from_parent_attrs = {"project_id": "project_id", "pipeline_id": "id"}
+
+
+class ProjectPipelineScheduleVariable(SaveMixin, ObjectDeleteMixin, RESTObject):
+    _id_attr = "key"
+
+
+class ProjectPipelineScheduleVariableManager(
+    CreateMixin[ProjectPipelineScheduleVariable],
+    UpdateMixin[ProjectPipelineScheduleVariable],
+    DeleteMixin[ProjectPipelineScheduleVariable],
+):
+    _path = "/projects/{project_id}/pipeline_schedules/{pipeline_schedule_id}/variables"
+    _obj_cls = ProjectPipelineScheduleVariable
+    _from_parent_attrs = {"project_id": "project_id", "pipeline_schedule_id": "id"}
+    _create_attrs = RequiredOptional(required=("key", "value"))
+    _update_attrs = RequiredOptional(required=("key", "value"))
+
+
+class ProjectPipelineSchedulePipeline(RESTObject):
+    pass
+
+
+class ProjectPipelineSchedulePipelineManager(
+    ListMixin[ProjectPipelineSchedulePipeline]
+):
+    _path = "/projects/{project_id}/pipeline_schedules/{pipeline_schedule_id}/pipelines"
+    _obj_cls = ProjectPipelineSchedulePipeline
+    _from_parent_attrs = {"project_id": "project_id", "pipeline_schedule_id": "id"}
+
+
+class ProjectPipelineSchedule(SaveMixin, ObjectDeleteMixin, RESTObject):
+    variables: ProjectPipelineScheduleVariableManager
+    pipelines: ProjectPipelineSchedulePipelineManager
+
+    @cli.register_custom_action(cls_names="ProjectPipelineSchedule")
+    @exc.on_http_error(exc.GitlabOwnershipError)
+    def take_ownership(self, **kwargs: Any) -> None:
+        """Update the owner of a pipeline schedule.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabOwnershipError: If the request failed
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/take_ownership"
+        server_data = self.manager.gitlab.http_post(path, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+        self._update_attrs(server_data)
+
+    @cli.register_custom_action(cls_names="ProjectPipelineSchedule")
+    @exc.on_http_error(exc.GitlabPipelinePlayError)
+    def play(self, **kwargs: Any) -> dict[str, Any]:
+        """Trigger a new scheduled pipeline, which runs immediately.
+        The next scheduled run of this pipeline is not affected.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabPipelinePlayError: If the request failed
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/play"
+        server_data = self.manager.gitlab.http_post(path, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+        self._update_attrs(server_data)
+        return server_data
+
+
+class ProjectPipelineScheduleManager(CRUDMixin[ProjectPipelineSchedule]):
+    _path = "/projects/{project_id}/pipeline_schedules"
+    _obj_cls = ProjectPipelineSchedule
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("description", "ref", "cron"), optional=("cron_timezone", "active")
+    )
+    _update_attrs = RequiredOptional(
+        optional=("description", "ref", "cron", "cron_timezone", "active")
+    )
+
+
+class ProjectPipelineTestReport(RESTObject):
+    _id_attr = None
+
+
+class ProjectPipelineTestReportManager(GetWithoutIdMixin[ProjectPipelineTestReport]):
+    _path = "/projects/{project_id}/pipelines/{pipeline_id}/test_report"
+    _obj_cls = ProjectPipelineTestReport
+    _from_parent_attrs = {"project_id": "project_id", "pipeline_id": "id"}
+
+
+class ProjectPipelineTestReportSummary(RESTObject):
+    _id_attr = None
+
+
+class ProjectPipelineTestReportSummaryManager(
+    GetWithoutIdMixin[ProjectPipelineTestReportSummary]
+):
+    _path = "/projects/{project_id}/pipelines/{pipeline_id}/test_report_summary"
+    _obj_cls = ProjectPipelineTestReportSummary
+    _from_parent_attrs = {"project_id": "project_id", "pipeline_id": "id"}
diff --git a/gitlab/v4/objects/project_access_tokens.py b/gitlab/v4/objects/project_access_tokens.py
new file mode 100644
index 000000000..912965519
--- /dev/null
+++ b/gitlab/v4/objects/project_access_tokens.py
@@ -0,0 +1,31 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    CreateMixin,
+    DeleteMixin,
+    ObjectDeleteMixin,
+    ObjectRotateMixin,
+    RetrieveMixin,
+    RotateMixin,
+)
+from gitlab.types import ArrayAttribute, RequiredOptional
+
+__all__ = ["ProjectAccessToken", "ProjectAccessTokenManager"]
+
+
+class ProjectAccessToken(ObjectDeleteMixin, ObjectRotateMixin, RESTObject):
+    pass
+
+
+class ProjectAccessTokenManager(
+    CreateMixin[ProjectAccessToken],
+    DeleteMixin[ProjectAccessToken],
+    RetrieveMixin[ProjectAccessToken],
+    RotateMixin[ProjectAccessToken],
+):
+    _path = "/projects/{project_id}/access_tokens"
+    _obj_cls = ProjectAccessToken
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("name", "scopes"), optional=("access_level", "expires_at")
+    )
+    _types = {"scopes": ArrayAttribute}
diff --git a/gitlab/v4/objects/projects.py b/gitlab/v4/objects/projects.py
new file mode 100644
index 000000000..b415a8b98
--- /dev/null
+++ b/gitlab/v4/objects/projects.py
@@ -0,0 +1,1338 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/projects.html
+"""
+
+from __future__ import annotations
+
+import io
+from typing import Any, Callable, Iterator, Literal, overload, TYPE_CHECKING
+
+import requests
+
+from gitlab import cli, client
+from gitlab import exceptions as exc
+from gitlab import types, utils
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    CreateMixin,
+    CRUDMixin,
+    DeleteMixin,
+    GetWithoutIdMixin,
+    ListMixin,
+    ObjectDeleteMixin,
+    RefreshMixin,
+    SaveMixin,
+    UpdateMixin,
+    UploadMixin,
+)
+from gitlab.types import RequiredOptional
+
+from .access_requests import ProjectAccessRequestManager  # noqa: F401
+from .artifacts import ProjectArtifactManager  # noqa: F401
+from .audit_events import ProjectAuditEventManager  # noqa: F401
+from .badges import ProjectBadgeManager  # noqa: F401
+from .boards import ProjectBoardManager  # noqa: F401
+from .branches import ProjectBranchManager, ProjectProtectedBranchManager  # noqa: F401
+from .ci_lint import ProjectCiLintManager  # noqa: F401
+from .cluster_agents import ProjectClusterAgentManager  # noqa: F401
+from .clusters import ProjectClusterManager  # noqa: F401
+from .commits import ProjectCommitManager  # noqa: F401
+from .container_registry import ProjectRegistryRepositoryManager  # noqa: F401
+from .custom_attributes import ProjectCustomAttributeManager  # noqa: F401
+from .deploy_keys import ProjectKeyManager  # noqa: F401
+from .deploy_tokens import ProjectDeployTokenManager  # noqa: F401
+from .deployments import ProjectDeploymentManager  # noqa: F401
+from .environments import (  # noqa: F401
+    ProjectEnvironmentManager,
+    ProjectProtectedEnvironmentManager,
+)
+from .events import ProjectEventManager  # noqa: F401
+from .export_import import ProjectExportManager, ProjectImportManager  # noqa: F401
+from .files import ProjectFileManager  # noqa: F401
+from .hooks import ProjectHookManager  # noqa: F401
+from .integrations import ProjectIntegrationManager, ProjectServiceManager  # noqa: F401
+from .invitations import ProjectInvitationManager  # noqa: F401
+from .issues import ProjectIssueManager  # noqa: F401
+from .iterations import ProjectIterationManager  # noqa: F401
+from .job_token_scope import ProjectJobTokenScopeManager  # noqa: F401
+from .jobs import ProjectJobManager  # noqa: F401
+from .labels import ProjectLabelManager  # noqa: F401
+from .members import ProjectMemberAllManager, ProjectMemberManager  # noqa: F401
+from .merge_request_approvals import (  # noqa: F401
+    ProjectApprovalManager,
+    ProjectApprovalRuleManager,
+)
+from .merge_requests import ProjectMergeRequestManager  # noqa: F401
+from .merge_trains import ProjectMergeTrainManager  # noqa: F401
+from .milestones import ProjectMilestoneManager  # noqa: F401
+from .notes import ProjectNoteManager  # noqa: F401
+from .notification_settings import ProjectNotificationSettingsManager  # noqa: F401
+from .package_protection_rules import ProjectPackageProtectionRuleManager
+from .packages import GenericPackageManager, ProjectPackageManager  # noqa: F401
+from .pages import ProjectPagesDomainManager, ProjectPagesManager  # noqa: F401
+from .pipelines import (  # noqa: F401
+    ProjectPipeline,
+    ProjectPipelineManager,
+    ProjectPipelineScheduleManager,
+)
+from .project_access_tokens import ProjectAccessTokenManager  # noqa: F401
+from .push_rules import ProjectPushRulesManager  # noqa: F401
+from .registry_protection_repository_rules import (  # noqa: F401
+    ProjectRegistryRepositoryProtectionRuleManager,
+)
+from .registry_protection_rules import (  # noqa: F401; deprecated
+    ProjectRegistryProtectionRuleManager,
+)
+from .releases import ProjectReleaseManager  # noqa: F401
+from .repositories import RepositoryMixin
+from .resource_groups import ProjectResourceGroupManager
+from .runners import ProjectRunnerManager  # noqa: F401
+from .secure_files import ProjectSecureFileManager  # noqa: F401
+from .snippets import ProjectSnippetManager  # noqa: F401
+from .statistics import (  # noqa: F401
+    ProjectAdditionalStatisticsManager,
+    ProjectIssuesStatisticsManager,
+)
+from .status_checks import ProjectExternalStatusCheckManager  # noqa: F401
+from .tags import ProjectProtectedTagManager, ProjectTagManager  # noqa: F401
+from .templates import (  # noqa: F401
+    ProjectDockerfileTemplateManager,
+    ProjectGitignoreTemplateManager,
+    ProjectGitlabciymlTemplateManager,
+    ProjectIssueTemplateManager,
+    ProjectLicenseTemplateManager,
+    ProjectMergeRequestTemplateManager,
+)
+from .triggers import ProjectTriggerManager  # noqa: F401
+from .users import ProjectUserManager  # noqa: F401
+from .variables import ProjectVariableManager  # noqa: F401
+from .wikis import ProjectWikiManager  # noqa: F401
+
+__all__ = [
+    "GroupProject",
+    "GroupProjectManager",
+    "Project",
+    "ProjectManager",
+    "ProjectFork",
+    "ProjectForkManager",
+    "ProjectRemoteMirror",
+    "ProjectRemoteMirrorManager",
+    "ProjectPullMirror",
+    "ProjectPullMirrorManager",
+    "ProjectStorage",
+    "ProjectStorageManager",
+    "SharedProject",
+    "SharedProjectManager",
+]
+
+
+class GroupProject(RESTObject):
+    pass
+
+
+class GroupProjectManager(ListMixin[GroupProject]):
+    _path = "/groups/{group_id}/projects"
+    _obj_cls = GroupProject
+    _from_parent_attrs = {"group_id": "id"}
+    _list_filters = (
+        "archived",
+        "visibility",
+        "order_by",
+        "sort",
+        "search",
+        "simple",
+        "owned",
+        "starred",
+        "with_custom_attributes",
+        "include_subgroups",
+        "with_issues_enabled",
+        "with_merge_requests_enabled",
+        "with_shared",
+        "min_access_level",
+        "with_security_reports",
+    )
+
+
+class ProjectGroup(RESTObject):
+    pass
+
+
+class ProjectGroupManager(ListMixin[ProjectGroup]):
+    _path = "/projects/{project_id}/groups"
+    _obj_cls = ProjectGroup
+    _from_parent_attrs = {"project_id": "id"}
+    _list_filters = (
+        "search",
+        "skip_groups",
+        "with_shared",
+        "shared_min_access_level",
+        "shared_visible_only",
+    )
+    _types = {"skip_groups": types.ArrayAttribute}
+
+
+class Project(
+    RefreshMixin, SaveMixin, ObjectDeleteMixin, RepositoryMixin, UploadMixin, RESTObject
+):
+    _repr_attr = "path_with_namespace"
+    _upload_path = "/projects/{id}/uploads"
+
+    access_tokens: ProjectAccessTokenManager
+    accessrequests: ProjectAccessRequestManager
+    additionalstatistics: ProjectAdditionalStatisticsManager
+    approvalrules: ProjectApprovalRuleManager
+    approvals: ProjectApprovalManager
+    artifacts: ProjectArtifactManager
+    audit_events: ProjectAuditEventManager
+    badges: ProjectBadgeManager
+    boards: ProjectBoardManager
+    branches: ProjectBranchManager
+    ci_lint: ProjectCiLintManager
+    clusters: ProjectClusterManager
+    cluster_agents: ProjectClusterAgentManager
+    commits: ProjectCommitManager
+    customattributes: ProjectCustomAttributeManager
+    deployments: ProjectDeploymentManager
+    deploytokens: ProjectDeployTokenManager
+    dockerfile_templates: ProjectDockerfileTemplateManager
+    environments: ProjectEnvironmentManager
+    events: ProjectEventManager
+    exports: ProjectExportManager
+    files: ProjectFileManager
+    forks: ProjectForkManager
+    generic_packages: GenericPackageManager
+    gitignore_templates: ProjectGitignoreTemplateManager
+    gitlabciyml_templates: ProjectGitlabciymlTemplateManager
+    groups: ProjectGroupManager
+    hooks: ProjectHookManager
+    imports: ProjectImportManager
+    integrations: ProjectIntegrationManager
+    invitations: ProjectInvitationManager
+    issues: ProjectIssueManager
+    issue_templates: ProjectIssueTemplateManager
+    issues_statistics: ProjectIssuesStatisticsManager
+    iterations: ProjectIterationManager
+    jobs: ProjectJobManager
+    job_token_scope: ProjectJobTokenScopeManager
+    keys: ProjectKeyManager
+    labels: ProjectLabelManager
+    license_templates: ProjectLicenseTemplateManager
+    members: ProjectMemberManager
+    members_all: ProjectMemberAllManager
+    mergerequests: ProjectMergeRequestManager
+    merge_request_templates: ProjectMergeRequestTemplateManager
+    merge_trains: ProjectMergeTrainManager
+    milestones: ProjectMilestoneManager
+    notes: ProjectNoteManager
+    notificationsettings: ProjectNotificationSettingsManager
+    packages: ProjectPackageManager
+    package_protection_rules: ProjectPackageProtectionRuleManager
+    pages: ProjectPagesManager
+    pagesdomains: ProjectPagesDomainManager
+    pipelines: ProjectPipelineManager
+    pipelineschedules: ProjectPipelineScheduleManager
+    protected_environments: ProjectProtectedEnvironmentManager
+    protectedbranches: ProjectProtectedBranchManager
+    protectedtags: ProjectProtectedTagManager
+    pushrules: ProjectPushRulesManager
+    registry_protection_rules: ProjectRegistryProtectionRuleManager
+    registry_protection_repository_rules: ProjectRegistryRepositoryProtectionRuleManager
+    releases: ProjectReleaseManager
+    resource_groups: ProjectResourceGroupManager
+    remote_mirrors: ProjectRemoteMirrorManager
+    pull_mirror: ProjectPullMirrorManager
+    repositories: ProjectRegistryRepositoryManager
+    runners: ProjectRunnerManager
+    secure_files: ProjectSecureFileManager
+    services: ProjectServiceManager
+    snippets: ProjectSnippetManager
+    external_status_checks: ProjectExternalStatusCheckManager
+    storage: ProjectStorageManager
+    tags: ProjectTagManager
+    triggers: ProjectTriggerManager
+    users: ProjectUserManager
+    variables: ProjectVariableManager
+    wikis: ProjectWikiManager
+
+    @cli.register_custom_action(cls_names="Project", required=("forked_from_id",))
+    @exc.on_http_error(exc.GitlabCreateError)
+    def create_fork_relation(self, forked_from_id: int, **kwargs: Any) -> None:
+        """Create a forked from/to relation between existing projects.
+
+        Args:
+            forked_from_id: The ID of the project that was forked from
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabCreateError: If the relation could not be created
+        """
+        path = f"/projects/{self.encoded_id}/fork/{forked_from_id}"
+        self.manager.gitlab.http_post(path, **kwargs)
+
+    @cli.register_custom_action(cls_names="Project")
+    @exc.on_http_error(exc.GitlabDeleteError)
+    def delete_fork_relation(self, **kwargs: Any) -> None:
+        """Delete a forked relation between existing projects.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabDeleteError: If the server failed to perform the request
+        """
+        path = f"/projects/{self.encoded_id}/fork"
+        self.manager.gitlab.http_delete(path, **kwargs)
+
+    @cli.register_custom_action(cls_names="Project")
+    @exc.on_http_error(exc.GitlabGetError)
+    def languages(self, **kwargs: Any) -> dict[str, Any] | requests.Response:
+        """Get languages used in the project with percentage value.
+
+        Args:
+            **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
+        """
+        path = f"/projects/{self.encoded_id}/languages"
+        return self.manager.gitlab.http_get(path, **kwargs)
+
+    @cli.register_custom_action(cls_names="Project")
+    @exc.on_http_error(exc.GitlabCreateError)
+    def star(self, **kwargs: Any) -> None:
+        """Star a project.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabCreateError: If the server failed to perform the request
+        """
+        path = f"/projects/{self.encoded_id}/star"
+        server_data = self.manager.gitlab.http_post(path, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+        self._update_attrs(server_data)
+
+    @cli.register_custom_action(cls_names="Project")
+    @exc.on_http_error(exc.GitlabDeleteError)
+    def unstar(self, **kwargs: Any) -> None:
+        """Unstar a project.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabDeleteError: If the server failed to perform the request
+        """
+        path = f"/projects/{self.encoded_id}/unstar"
+        server_data = self.manager.gitlab.http_post(path, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+        self._update_attrs(server_data)
+
+    @cli.register_custom_action(cls_names="Project")
+    @exc.on_http_error(exc.GitlabCreateError)
+    def archive(self, **kwargs: Any) -> None:
+        """Archive a project.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabCreateError: If the server failed to perform the request
+        """
+        path = f"/projects/{self.encoded_id}/archive"
+        server_data = self.manager.gitlab.http_post(path, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+        self._update_attrs(server_data)
+
+    @cli.register_custom_action(cls_names="Project")
+    @exc.on_http_error(exc.GitlabDeleteError)
+    def unarchive(self, **kwargs: Any) -> None:
+        """Unarchive a project.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabDeleteError: If the server failed to perform the request
+        """
+        path = f"/projects/{self.encoded_id}/unarchive"
+        server_data = self.manager.gitlab.http_post(path, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+        self._update_attrs(server_data)
+
+    @cli.register_custom_action(
+        cls_names="Project",
+        required=("group_id", "group_access"),
+        optional=("expires_at",),
+    )
+    @exc.on_http_error(exc.GitlabCreateError)
+    def share(
+        self,
+        group_id: int,
+        group_access: int,
+        expires_at: str | None = None,
+        **kwargs: Any,
+    ) -> None:
+        """Share the project with a group.
+
+        Args:
+            group_id: ID of the group.
+            group_access: Access level for the group.
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabCreateError: If the server failed to perform the request
+        """
+        path = f"/projects/{self.encoded_id}/share"
+        data = {
+            "group_id": group_id,
+            "group_access": group_access,
+            "expires_at": expires_at,
+        }
+        self.manager.gitlab.http_post(path, post_data=data, **kwargs)
+
+    @cli.register_custom_action(cls_names="Project", required=("group_id",))
+    @exc.on_http_error(exc.GitlabDeleteError)
+    def unshare(self, group_id: int, **kwargs: Any) -> None:
+        """Delete a shared project link within a group.
+
+        Args:
+            group_id: ID of the group.
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabDeleteError: If the server failed to perform the request
+        """
+        path = f"/projects/{self.encoded_id}/share/{group_id}"
+        self.manager.gitlab.http_delete(path, **kwargs)
+
+    # variables not supported in CLI
+    @cli.register_custom_action(cls_names="Project", required=("ref", "token"))
+    @exc.on_http_error(exc.GitlabCreateError)
+    def trigger_pipeline(
+        self,
+        ref: str,
+        token: str,
+        variables: dict[str, Any] | None = None,
+        inputs: dict[str, Any] | None = None,
+        **kwargs: Any,
+    ) -> ProjectPipeline:
+        """Trigger a CI build.
+
+        See https://gitlab.com/help/ci/triggers/README.md#trigger-a-build
+
+        Args:
+            ref: Commit to build; can be a branch name or a tag
+            token: The trigger token
+            variables: Variables passed to the build script
+            inputs: Inputs passed to the build script
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabCreateError: If the server failed to perform the request
+        """
+        variables = variables or {}
+        inputs = inputs or {}
+        path = f"/projects/{self.encoded_id}/trigger/pipeline"
+        post_data = {
+            "ref": ref,
+            "token": token,
+            "variables": variables,
+            "inputs": inputs,
+        }
+        attrs = self.manager.gitlab.http_post(path, post_data=post_data, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(attrs, dict)
+        return ProjectPipeline(self.pipelines, attrs)
+
+    @cli.register_custom_action(cls_names="Project")
+    @exc.on_http_error(exc.GitlabHousekeepingError)
+    def housekeeping(self, **kwargs: Any) -> None:
+        """Start the housekeeping task.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabHousekeepingError: If the server failed to perform the
+                                     request
+        """
+        path = f"/projects/{self.encoded_id}/housekeeping"
+        self.manager.gitlab.http_post(path, **kwargs)
+
+    @cli.register_custom_action(cls_names="Project")
+    @exc.on_http_error(exc.GitlabRestoreError)
+    def restore(self, **kwargs: Any) -> None:
+        """Restore a project marked for deletion.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabRestoreError: If the server failed to perform the request
+        """
+        path = f"/projects/{self.encoded_id}/restore"
+        self.manager.gitlab.http_post(path, **kwargs)
+
+    @overload
+    def snapshot(
+        self,
+        wiki: bool = False,
+        streamed: Literal[False] = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> bytes: ...
+
+    @overload
+    def snapshot(
+        self,
+        wiki: bool = False,
+        streamed: bool = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[True] = True,
+        **kwargs: Any,
+    ) -> Iterator[Any]: ...
+
+    @overload
+    def snapshot(
+        self,
+        wiki: bool = False,
+        streamed: Literal[True] = True,
+        action: Callable[[bytes], Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> None: ...
+
+    @cli.register_custom_action(cls_names="Project", optional=("wiki",))
+    @exc.on_http_error(exc.GitlabGetError)
+    def snapshot(
+        self,
+        wiki: bool = False,
+        streamed: bool = False,
+        action: Callable[[bytes], Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: bool = False,
+        **kwargs: Any,
+    ) -> bytes | Iterator[Any] | None:
+        """Return a snapshot of the repository.
+
+        Args:
+            wiki: If True return the wiki repository
+            streamed: If True the data will be processed by chunks of
+                `chunk_size` and each chunk is passed to `action` for
+                treatment.
+            iterator: If True directly return the underlying response
+                iterator
+            action: Callable responsible of dealing with chunk of
+                data
+            chunk_size: 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 content could not be retrieved
+
+        Returns:
+            The uncompressed tar archive of the repository
+        """
+        path = f"/projects/{self.encoded_id}/snapshot"
+        result = self.manager.gitlab.http_get(
+            path, streamed=streamed, raw=True, wiki=wiki, **kwargs
+        )
+        if TYPE_CHECKING:
+            assert isinstance(result, requests.Response)
+        return utils.response_content(
+            result, streamed, action, chunk_size, iterator=iterator
+        )
+
+    @cli.register_custom_action(cls_names="Project", required=("scope", "search"))
+    @exc.on_http_error(exc.GitlabSearchError)
+    def search(
+        self, scope: str, search: str, **kwargs: Any
+    ) -> client.GitlabList | list[dict[str, Any]]:
+        """Search the project resources matching the provided string.'
+
+        Args:
+            scope: Scope of the search
+            search: Search string
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabSearchError: If the server failed to perform the request
+
+        Returns:
+            A list of dicts describing the resources found.
+        """
+        data = {"scope": scope, "search": search}
+        path = f"/projects/{self.encoded_id}/search"
+        return self.manager.gitlab.http_list(path, query_data=data, **kwargs)
+
+    @cli.register_custom_action(cls_names="Project")
+    @exc.on_http_error(exc.GitlabCreateError)
+    def mirror_pull(self, **kwargs: Any) -> None:
+        """Start the pull mirroring process for the project.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabCreateError: If the server failed to perform the request
+        """
+        utils.warn(
+            message=(
+                "project.mirror_pull() is deprecated and will be removed in a "
+                "future major version. Use project.pull_mirror.start() instead."
+            ),
+            category=DeprecationWarning,
+        )
+        path = f"/projects/{self.encoded_id}/mirror/pull"
+        self.manager.gitlab.http_post(path, **kwargs)
+
+    @cli.register_custom_action(cls_names="Project")
+    @exc.on_http_error(exc.GitlabGetError)
+    def mirror_pull_details(self, **kwargs: Any) -> dict[str, Any]:
+        """Get a project's pull mirror details.
+
+        Introduced in GitLab 15.5.
+
+        Args:
+            **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:
+            dict of the parsed json returned by the server
+        """
+        utils.warn(
+            message=(
+                "project.mirror_pull_details() is deprecated and will be removed in a "
+                "future major version. Use project.pull_mirror.get() instead."
+            ),
+            category=DeprecationWarning,
+        )
+        path = f"/projects/{self.encoded_id}/mirror/pull"
+        result = self.manager.gitlab.http_get(path, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(result, dict)
+        return result
+
+    @cli.register_custom_action(cls_names="Project", required=("to_namespace",))
+    @exc.on_http_error(exc.GitlabTransferProjectError)
+    def transfer(self, to_namespace: int | str, **kwargs: Any) -> None:
+        """Transfer a project to the given namespace ID
+
+        Args:
+            to_namespace: ID or path of the namespace to transfer the
+            project to
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabTransferProjectError: If the project could not be transferred
+        """
+        path = f"/projects/{self.encoded_id}/transfer"
+        self.manager.gitlab.http_put(
+            path, post_data={"namespace": to_namespace}, **kwargs
+        )
+
+
+class ProjectManager(CRUDMixin[Project]):
+    _path = "/projects"
+    _obj_cls = Project
+    # Please keep these _create_attrs in same order as they are at:
+    # https://docs.gitlab.com/ee/api/projects.html#create-project
+    _create_attrs = RequiredOptional(
+        optional=(
+            "name",
+            "path",
+            "allow_merge_on_skipped_pipeline",
+            "only_allow_merge_if_all_status_checks_passed",
+            "analytics_access_level",
+            "approvals_before_merge",
+            "auto_cancel_pending_pipelines",
+            "auto_devops_deploy_strategy",
+            "auto_devops_enabled",
+            "autoclose_referenced_issues",
+            "avatar",
+            "build_coverage_regex",
+            "build_git_strategy",
+            "build_timeout",
+            "builds_access_level",
+            "ci_config_path",
+            "container_expiration_policy_attributes",
+            "container_registry_access_level",
+            "container_registry_enabled",
+            "default_branch",
+            "description",
+            "emails_disabled",
+            "external_authorization_classification_label",
+            "forking_access_level",
+            "group_with_project_templates_id",
+            "import_url",
+            "initialize_with_readme",
+            "issues_access_level",
+            "issues_enabled",
+            "jobs_enabled",
+            "lfs_enabled",
+            "merge_method",
+            "merge_pipelines_enabled",
+            "merge_requests_access_level",
+            "merge_requests_enabled",
+            "mirror_trigger_builds",
+            "mirror",
+            "namespace_id",
+            "operations_access_level",
+            "only_allow_merge_if_all_discussions_are_resolved",
+            "only_allow_merge_if_pipeline_succeeds",
+            "packages_enabled",
+            "pages_access_level",
+            "requirements_access_level",
+            "printing_merge_request_link_enabled",
+            "public_builds",
+            "releases_access_level",
+            "environments_access_level",
+            "feature_flags_access_level",
+            "infrastructure_access_level",
+            "monitor_access_level",
+            "remove_source_branch_after_merge",
+            "repository_access_level",
+            "repository_storage",
+            "request_access_enabled",
+            "resolve_outdated_diff_discussions",
+            "security_and_compliance_access_level",
+            "shared_runners_enabled",
+            "show_default_award_emojis",
+            "snippets_access_level",
+            "snippets_enabled",
+            "squash_option",
+            "tag_list",
+            "topics",
+            "template_name",
+            "template_project_id",
+            "use_custom_template",
+            "visibility",
+            "wiki_access_level",
+            "wiki_enabled",
+        )
+    )
+    # Please keep these _update_attrs in same order as they are at:
+    # https://docs.gitlab.com/ee/api/projects.html#edit-project
+    _update_attrs = RequiredOptional(
+        optional=(
+            "allow_merge_on_skipped_pipeline",
+            "only_allow_merge_if_all_status_checks_passed",
+            "analytics_access_level",
+            "approvals_before_merge",
+            "auto_cancel_pending_pipelines",
+            "auto_devops_deploy_strategy",
+            "auto_devops_enabled",
+            "autoclose_referenced_issues",
+            "avatar",
+            "build_coverage_regex",
+            "build_git_strategy",
+            "build_timeout",
+            "builds_access_level",
+            "ci_config_path",
+            "ci_default_git_depth",
+            "ci_forward_deployment_enabled",
+            "ci_allow_fork_pipelines_to_run_in_parent_project",
+            "ci_separated_caches",
+            "container_expiration_policy_attributes",
+            "container_registry_access_level",
+            "container_registry_enabled",
+            "default_branch",
+            "description",
+            "emails_disabled",
+            "enforce_auth_checks_on_uploads",
+            "external_authorization_classification_label",
+            "forking_access_level",
+            "import_url",
+            "issues_access_level",
+            "issues_enabled",
+            "issues_template",
+            "jobs_enabled",
+            "keep_latest_artifact",
+            "lfs_enabled",
+            "merge_commit_template",
+            "merge_method",
+            "merge_pipelines_enabled",
+            "merge_requests_access_level",
+            "merge_requests_enabled",
+            "merge_requests_template",
+            "merge_trains_enabled",
+            "mirror_overwrites_diverged_branches",
+            "mirror_trigger_builds",
+            "mirror_user_id",
+            "mirror",
+            "mr_default_target_self",
+            "name",
+            "operations_access_level",
+            "only_allow_merge_if_all_discussions_are_resolved",
+            "only_allow_merge_if_pipeline_succeeds",
+            "only_mirror_protected_branches",
+            "packages_enabled",
+            "pages_access_level",
+            "requirements_access_level",
+            "restrict_user_defined_variables",
+            "path",
+            "public_builds",
+            "releases_access_level",
+            "environments_access_level",
+            "feature_flags_access_level",
+            "infrastructure_access_level",
+            "monitor_access_level",
+            "remove_source_branch_after_merge",
+            "repository_access_level",
+            "repository_storage",
+            "request_access_enabled",
+            "resolve_outdated_diff_discussions",
+            "security_and_compliance_access_level",
+            "service_desk_enabled",
+            "shared_runners_enabled",
+            "show_default_award_emojis",
+            "snippets_access_level",
+            "snippets_enabled",
+            "issue_branch_template",
+            "squash_commit_template",
+            "squash_option",
+            "suggestion_commit_message",
+            "tag_list",
+            "topics",
+            "visibility",
+            "wiki_access_level",
+            "wiki_enabled",
+        )
+    )
+    _list_filters = (
+        "archived",
+        "id_after",
+        "id_before",
+        "last_activity_after",
+        "last_activity_before",
+        "membership",
+        "min_access_level",
+        "order_by",
+        "owned",
+        "repository_checksum_failed",
+        "repository_storage",
+        "search_namespaces",
+        "search",
+        "simple",
+        "sort",
+        "starred",
+        "statistics",
+        "topic",
+        "visibility",
+        "wiki_checksum_failed",
+        "with_custom_attributes",
+        "with_issues_enabled",
+        "with_merge_requests_enabled",
+        "with_programming_language",
+    )
+    _types = {
+        "avatar": types.ImageAttribute,
+        "topic": types.CommaSeparatedListAttribute,
+        "topics": types.ArrayAttribute,
+    }
+
+    @exc.on_http_error(exc.GitlabImportError)
+    def import_project(
+        self,
+        file: io.BufferedReader,
+        path: str,
+        name: str | None = None,
+        namespace: str | None = None,
+        overwrite: bool = False,
+        override_params: dict[str, Any] | None = None,
+        **kwargs: Any,
+    ) -> dict[str, Any] | requests.Response:
+        """Import a project from an archive file.
+
+        Args:
+            file: Data or file object containing the project
+            path: Name and path for the new project
+            name: The name of the project to import. If not provided,
+                defaults to the path of the project.
+            namespace: The ID or path of the namespace that the project
+                will be imported to
+            overwrite: If True overwrite an existing project with the
+                same path
+            override_params: Set the specific settings for the project
+            **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:
+            A representation of the import status.
+        """
+        files = {"file": ("file.tar.gz", file, "application/octet-stream")}
+        data = {"path": path, "overwrite": str(overwrite)}
+        if override_params:
+            for k, v in override_params.items():
+                data[f"override_params[{k}]"] = v
+        if name is not None:
+            data["name"] = name
+        if namespace:
+            data["namespace"] = namespace
+        return self.gitlab.http_post(
+            "/projects/import", post_data=data, files=files, **kwargs
+        )
+
+    @exc.on_http_error(exc.GitlabImportError)
+    def remote_import(
+        self,
+        url: str,
+        path: str,
+        name: str | None = None,
+        namespace: str | None = None,
+        overwrite: bool = False,
+        override_params: dict[str, Any] | None = None,
+        **kwargs: Any,
+    ) -> dict[str, Any] | requests.Response:
+        """Import a project from an archive file stored on a remote URL.
+
+        Args:
+            url: URL for the file containing the project data to import
+            path: Name and path for the new project
+            name: The name of the project to import. If not provided,
+                defaults to the path of the project.
+            namespace: The ID or path of the namespace that the project
+                will be imported to
+            overwrite: If True overwrite an existing project with the
+                same path
+            override_params: Set the specific settings for the project
+            **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:
+            A representation of the import status.
+        """
+        data = {"path": path, "overwrite": str(overwrite), "url": url}
+        if override_params:
+            for k, v in override_params.items():
+                data[f"override_params[{k}]"] = v
+        if name is not None:
+            data["name"] = name
+        if namespace:
+            data["namespace"] = namespace
+        return self.gitlab.http_post(
+            "/projects/remote-import", post_data=data, **kwargs
+        )
+
+    @exc.on_http_error(exc.GitlabImportError)
+    def remote_import_s3(
+        self,
+        path: str,
+        region: str,
+        bucket_name: str,
+        file_key: str,
+        access_key_id: str,
+        secret_access_key: str,
+        name: str | None = None,
+        namespace: str | None = None,
+        overwrite: bool = False,
+        override_params: dict[str, Any] | None = None,
+        **kwargs: Any,
+    ) -> dict[str, Any] | requests.Response:
+        """Import a project from an archive file stored on AWS S3.
+
+        Args:
+            region: AWS S3 region name where the file is stored
+            bucket_name: AWS S3 bucket name where the file is stored
+            file_key: AWS S3 file key to identify the file.
+            access_key_id: AWS S3 access key ID.
+            secret_access_key: AWS S3 secret access key.
+            path: Name and path for the new project
+            name: The name of the project to import. If not provided,
+                defaults to the path of the project.
+            namespace: The ID or path of the namespace that the project
+                will be imported to
+            overwrite: If True overwrite an existing project with the
+                same path
+            override_params: Set the specific settings for the project
+            **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:
+            A representation of the import status.
+        """
+        data = {
+            "region": region,
+            "bucket_name": bucket_name,
+            "file_key": file_key,
+            "access_key_id": access_key_id,
+            "secret_access_key": secret_access_key,
+            "path": path,
+            "overwrite": str(overwrite),
+        }
+        if override_params:
+            for k, v in override_params.items():
+                data[f"override_params[{k}]"] = v
+        if name is not None:
+            data["name"] = name
+        if namespace:
+            data["namespace"] = namespace
+        return self.gitlab.http_post(
+            "/projects/remote-import-s3", post_data=data, **kwargs
+        )
+
+    def import_bitbucket_server(
+        self,
+        bitbucket_server_url: str,
+        bitbucket_server_username: str,
+        personal_access_token: str,
+        bitbucket_server_project: str,
+        bitbucket_server_repo: str,
+        new_name: str | None = None,
+        target_namespace: str | None = None,
+        **kwargs: Any,
+    ) -> dict[str, Any] | requests.Response:
+        """Import a project from BitBucket Server to Gitlab (schedule the import)
+
+        This method will return when an import operation has been safely queued,
+        or an error has occurred. After triggering an import, check the
+        ``import_status`` of the newly created project to detect when the import
+        operation has completed.
+
+        .. note::
+            This request may take longer than most other API requests.
+            So this method will specify a 60 second default timeout if none is
+            specified.
+            A timeout can be specified via kwargs to override this functionality.
+
+        Args:
+            bitbucket_server_url: Bitbucket Server URL
+            bitbucket_server_username: Bitbucket Server Username
+            personal_access_token: Bitbucket Server personal access
+                token/password
+            bitbucket_server_project: Bitbucket Project Key
+            bitbucket_server_repo: Bitbucket Repository Name
+            new_name: New repository name (Optional)
+            target_namespace: Namespace to import repository into.
+                Supports subgroups like /namespace/subgroup (Optional)
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabListError: If the server failed to perform the request
+
+        Returns:
+            A representation of the import status.
+
+        Example:
+
+        .. code-block:: python
+
+            gl = gitlab.Gitlab_from_config()
+            print("Triggering import")
+            result = gl.projects.import_bitbucket_server(
+                bitbucket_server_url="https://some.server.url",
+                bitbucket_server_username="some_bitbucket_user",
+                personal_access_token="my_password_or_access_token",
+                bitbucket_server_project="my_project",
+                bitbucket_server_repo="my_repo",
+                new_name="gl_project_name",
+                target_namespace="gl_project_path"
+            )
+            project = gl.projects.get(ret['id'])
+            print("Waiting for import to complete")
+            while project.import_status == u'started':
+                time.sleep(1.0)
+                project = gl.projects.get(project.id)
+            print("BitBucket import complete")
+
+        """
+        data = {
+            "bitbucket_server_url": bitbucket_server_url,
+            "bitbucket_server_username": bitbucket_server_username,
+            "personal_access_token": personal_access_token,
+            "bitbucket_server_project": bitbucket_server_project,
+            "bitbucket_server_repo": bitbucket_server_repo,
+        }
+        if new_name:
+            data["new_name"] = new_name
+        if target_namespace:
+            data["target_namespace"] = target_namespace
+        if (
+            "timeout" not in kwargs
+            or self.gitlab.timeout is None
+            or self.gitlab.timeout < 60.0
+        ):
+            # Ensure that this HTTP request has a longer-than-usual default timeout
+            # The base gitlab object tends to have a default that is <10 seconds,
+            # and this is too short for this API command, typically.
+            # On the order of 24 seconds has been measured on a typical gitlab instance.
+            kwargs["timeout"] = 60.0
+        result = self.gitlab.http_post(
+            "/import/bitbucket_server", post_data=data, **kwargs
+        )
+        return result
+
+    def import_github(
+        self,
+        personal_access_token: str,
+        repo_id: int,
+        target_namespace: str,
+        new_name: str | None = None,
+        github_hostname: str | None = None,
+        optional_stages: dict[str, bool] | None = None,
+        **kwargs: Any,
+    ) -> dict[str, Any] | requests.Response:
+        """Import a project from Github to Gitlab (schedule the import)
+
+        This method will return when an import operation has been safely queued,
+        or an error has occurred. After triggering an import, check the
+        ``import_status`` of the newly created project to detect when the import
+        operation has completed.
+
+        .. note::
+            This request may take longer than most other API requests.
+            So this method will specify a 60 second default timeout if none is
+            specified.
+            A timeout can be specified via kwargs to override this functionality.
+
+        Args:
+            personal_access_token: GitHub personal access token
+            repo_id: Github repository ID
+            target_namespace: Namespace to import repo into
+            new_name: New repo name (Optional)
+            github_hostname: Custom GitHub Enterprise hostname.
+                Do not set for GitHub.com. (Optional)
+            optional_stages: Additional items to import. (Optional)
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabListError: If the server failed to perform the request
+
+        Returns:
+            A representation of the import status.
+
+        Example:
+
+        .. code-block:: python
+
+            gl = gitlab.Gitlab_from_config()
+            print("Triggering import")
+            result = gl.projects.import_github(ACCESS_TOKEN,
+                                               123456,
+                                               "my-group/my-subgroup")
+            project = gl.projects.get(ret['id'])
+            print("Waiting for import to complete")
+            while project.import_status == u'started':
+                time.sleep(1.0)
+                project = gl.projects.get(project.id)
+            print("Github import complete")
+
+        """
+        data = {
+            "personal_access_token": personal_access_token,
+            "repo_id": repo_id,
+            "target_namespace": target_namespace,
+            "new_name": new_name,
+            "github_hostname": github_hostname,
+            "optional_stages": optional_stages,
+        }
+        data = utils.remove_none_from_dict(data)
+
+        if (
+            "timeout" not in kwargs
+            or self.gitlab.timeout is None
+            or self.gitlab.timeout < 60.0
+        ):
+            # Ensure that this HTTP request has a longer-than-usual default timeout
+            # The base gitlab object tends to have a default that is <10 seconds,
+            # and this is too short for this API command, typically.
+            # On the order of 24 seconds has been measured on a typical gitlab instance.
+            kwargs["timeout"] = 60.0
+        result = self.gitlab.http_post("/import/github", post_data=data, **kwargs)
+        return result
+
+
+class ProjectFork(RESTObject):
+    pass
+
+
+class ProjectForkManager(CreateMixin[ProjectFork], ListMixin[ProjectFork]):
+    _path = "/projects/{project_id}/forks"
+    _obj_cls = ProjectFork
+    _from_parent_attrs = {"project_id": "id"}
+    _list_filters = (
+        "archived",
+        "visibility",
+        "order_by",
+        "sort",
+        "search",
+        "simple",
+        "owned",
+        "membership",
+        "starred",
+        "statistics",
+        "with_custom_attributes",
+        "with_issues_enabled",
+        "with_merge_requests_enabled",
+    )
+    _create_attrs = RequiredOptional(optional=("namespace",))
+
+    def create(self, data: dict[str, Any] | None = None, **kwargs: Any) -> ProjectFork:
+        """Creates a new object.
+
+        Args:
+            data: Parameters to send to the server to create the
+                         resource
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabCreateError: If the server cannot perform the request
+
+        Returns:
+            A new instance of the managed object class build with
+                the data sent by the server
+        """
+        path = self.path[:-1]  # drop the 's'
+        return super().create(data, path=path, **kwargs)
+
+
+class ProjectRemoteMirror(ObjectDeleteMixin, SaveMixin, RESTObject):
+    pass
+
+
+class ProjectRemoteMirrorManager(
+    ListMixin[ProjectRemoteMirror],
+    CreateMixin[ProjectRemoteMirror],
+    UpdateMixin[ProjectRemoteMirror],
+    DeleteMixin[ProjectRemoteMirror],
+):
+    _path = "/projects/{project_id}/remote_mirrors"
+    _obj_cls = ProjectRemoteMirror
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("url",), optional=("enabled", "only_protected_branches")
+    )
+    _update_attrs = RequiredOptional(optional=("enabled", "only_protected_branches"))
+
+
+class ProjectPullMirror(SaveMixin, RESTObject):
+    _id_attr = None
+
+
+class ProjectPullMirrorManager(
+    GetWithoutIdMixin[ProjectPullMirror], UpdateMixin[ProjectPullMirror]
+):
+    _path = "/projects/{project_id}/mirror/pull"
+    _obj_cls = ProjectPullMirror
+    _from_parent_attrs = {"project_id": "id"}
+    _update_attrs = RequiredOptional(optional=("url",))
+
+    @exc.on_http_error(exc.GitlabCreateError)
+    def create(self, data: dict[str, Any], **kwargs: Any) -> ProjectPullMirror:
+        """Create a new object.
+
+        Args:
+            data: parameters to send to the server to create the
+                         resource
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Returns:
+            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
+        """
+        if TYPE_CHECKING:
+            assert data is not None
+        self._create_attrs.validate_attrs(data=data)
+
+        server_data = self.gitlab.http_put(self.path, post_data=data, **kwargs)
+
+        if TYPE_CHECKING:
+            assert not isinstance(server_data, requests.Response)
+        return self._obj_cls(self, server_data)
+
+    @cli.register_custom_action(cls_names="ProjectPullMirrorManager")
+    @exc.on_http_error(exc.GitlabCreateError)
+    def start(self, **kwargs: Any) -> None:
+        """Start the pull mirroring process for the project.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabCreateError: If the server failed to perform the request
+        """
+        self.gitlab.http_post(self.path, **kwargs)
+
+
+class ProjectStorage(RefreshMixin, RESTObject):
+    pass
+
+
+class ProjectStorageManager(GetWithoutIdMixin[ProjectStorage]):
+    _path = "/projects/{project_id}/storage"
+    _obj_cls = ProjectStorage
+    _from_parent_attrs = {"project_id": "id"}
+
+
+class SharedProject(RESTObject):
+    pass
+
+
+class SharedProjectManager(ListMixin[SharedProject]):
+    _path = "/groups/{group_id}/projects/shared"
+    _obj_cls = SharedProject
+    _from_parent_attrs = {"group_id": "id"}
+    _list_filters = (
+        "archived",
+        "visibility",
+        "order_by",
+        "sort",
+        "search",
+        "simple",
+        "starred",
+        "with_issues_enabled",
+        "with_merge_requests_enabled",
+        "min_access_level",
+        "with_custom_attributes",
+    )
diff --git a/gitlab/v4/objects/push_rules.py b/gitlab/v4/objects/push_rules.py
new file mode 100644
index 000000000..2ba526597
--- /dev/null
+++ b/gitlab/v4/objects/push_rules.py
@@ -0,0 +1,107 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    CreateMixin,
+    DeleteMixin,
+    GetWithoutIdMixin,
+    ObjectDeleteMixin,
+    SaveMixin,
+    UpdateMixin,
+)
+from gitlab.types import RequiredOptional
+
+__all__ = [
+    "GroupPushRules",
+    "GroupPushRulesManager",
+    "ProjectPushRules",
+    "ProjectPushRulesManager",
+]
+
+
+class ProjectPushRules(SaveMixin, ObjectDeleteMixin, RESTObject):
+    _id_attr = None
+
+
+class ProjectPushRulesManager(
+    GetWithoutIdMixin[ProjectPushRules],
+    CreateMixin[ProjectPushRules],
+    UpdateMixin[ProjectPushRules],
+    DeleteMixin[ProjectPushRules],
+):
+    _path = "/projects/{project_id}/push_rule"
+    _obj_cls = ProjectPushRules
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        optional=(
+            "author_email_regex",
+            "branch_name_regex",
+            "commit_committer_check",
+            "commit_message_negative_regex",
+            "commit_message_regex",
+            "deny_delete_tag",
+            "file_name_regex",
+            "max_file_size",
+            "member_check",
+            "prevent_secrets",
+            "reject_unsigned_commits",
+        )
+    )
+    _update_attrs = RequiredOptional(
+        optional=(
+            "author_email_regex",
+            "branch_name_regex",
+            "commit_committer_check",
+            "commit_message_negative_regex",
+            "commit_message_regex",
+            "deny_delete_tag",
+            "file_name_regex",
+            "max_file_size",
+            "member_check",
+            "prevent_secrets",
+            "reject_unsigned_commits",
+        )
+    )
+
+
+class GroupPushRules(SaveMixin, ObjectDeleteMixin, RESTObject):
+    _id_attr = None
+
+
+class GroupPushRulesManager(
+    GetWithoutIdMixin[GroupPushRules],
+    CreateMixin[GroupPushRules],
+    UpdateMixin[GroupPushRules],
+    DeleteMixin[GroupPushRules],
+):
+    _path = "/groups/{group_id}/push_rule"
+    _obj_cls = GroupPushRules
+    _from_parent_attrs = {"group_id": "id"}
+    _create_attrs = RequiredOptional(
+        optional=(
+            "deny_delete_tag",
+            "member_check",
+            "prevent_secrets",
+            "commit_message_regex",
+            "commit_message_negative_regex",
+            "branch_name_regex",
+            "author_email_regex",
+            "file_name_regex",
+            "max_file_size",
+            "commit_committer_check",
+            "reject_unsigned_commits",
+        )
+    )
+    _update_attrs = RequiredOptional(
+        optional=(
+            "deny_delete_tag",
+            "member_check",
+            "prevent_secrets",
+            "commit_message_regex",
+            "commit_message_negative_regex",
+            "branch_name_regex",
+            "author_email_regex",
+            "file_name_regex",
+            "max_file_size",
+            "commit_committer_check",
+            "reject_unsigned_commits",
+        )
+    )
diff --git a/gitlab/v4/objects/registry_protection_repository_rules.py b/gitlab/v4/objects/registry_protection_repository_rules.py
new file mode 100644
index 000000000..19d4bdf59
--- /dev/null
+++ b/gitlab/v4/objects/registry_protection_repository_rules.py
@@ -0,0 +1,34 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import CreateMixin, ListMixin, SaveMixin, UpdateMethod, UpdateMixin
+from gitlab.types import RequiredOptional
+
+__all__ = [
+    "ProjectRegistryRepositoryProtectionRule",
+    "ProjectRegistryRepositoryProtectionRuleManager",
+]
+
+
+class ProjectRegistryRepositoryProtectionRule(SaveMixin, RESTObject):
+    _repr_attr = "repository_path_pattern"
+
+
+class ProjectRegistryRepositoryProtectionRuleManager(
+    ListMixin[ProjectRegistryRepositoryProtectionRule],
+    CreateMixin[ProjectRegistryRepositoryProtectionRule],
+    UpdateMixin[ProjectRegistryRepositoryProtectionRule],
+):
+    _path = "/projects/{project_id}/registry/protection/repository/rules"
+    _obj_cls = ProjectRegistryRepositoryProtectionRule
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("repository_path_pattern",),
+        optional=("minimum_access_level_for_push", "minimum_access_level_for_delete"),
+    )
+    _update_attrs = RequiredOptional(
+        optional=(
+            "repository_path_pattern",
+            "minimum_access_level_for_push",
+            "minimum_access_level_for_delete",
+        )
+    )
+    _update_method = UpdateMethod.PATCH
diff --git a/gitlab/v4/objects/registry_protection_rules.py b/gitlab/v4/objects/registry_protection_rules.py
new file mode 100644
index 000000000..9ea34028b
--- /dev/null
+++ b/gitlab/v4/objects/registry_protection_rules.py
@@ -0,0 +1,31 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import CreateMixin, ListMixin, SaveMixin, UpdateMethod, UpdateMixin
+from gitlab.types import RequiredOptional
+
+__all__ = ["ProjectRegistryProtectionRule", "ProjectRegistryProtectionRuleManager"]
+
+
+class ProjectRegistryProtectionRule(SaveMixin, RESTObject):
+    _repr_attr = "repository_path_pattern"
+
+
+class ProjectRegistryProtectionRuleManager(
+    ListMixin[ProjectRegistryProtectionRule],
+    CreateMixin[ProjectRegistryProtectionRule],
+    UpdateMixin[ProjectRegistryProtectionRule],
+):
+    _path = "/projects/{project_id}/registry/protection/rules"
+    _obj_cls = ProjectRegistryProtectionRule
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("repository_path_pattern",),
+        optional=("minimum_access_level_for_push", "minimum_access_level_for_delete"),
+    )
+    _update_attrs = RequiredOptional(
+        optional=(
+            "repository_path_pattern",
+            "minimum_access_level_for_push",
+            "minimum_access_level_for_delete",
+        )
+    )
+    _update_method = UpdateMethod.PATCH
diff --git a/gitlab/v4/objects/releases.py b/gitlab/v4/objects/releases.py
new file mode 100644
index 000000000..f082880d3
--- /dev/null
+++ b/gitlab/v4/objects/releases.py
@@ -0,0 +1,49 @@
+from __future__ import annotations
+
+from gitlab.base import RESTObject
+from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin
+from gitlab.types import ArrayAttribute, RequiredOptional
+
+__all__ = [
+    "ProjectRelease",
+    "ProjectReleaseManager",
+    "ProjectReleaseLink",
+    "ProjectReleaseLinkManager",
+]
+
+
+class ProjectRelease(SaveMixin, RESTObject):
+    _id_attr = "tag_name"
+
+    links: ProjectReleaseLinkManager
+
+
+class ProjectReleaseManager(CRUDMixin[ProjectRelease]):
+    _path = "/projects/{project_id}/releases"
+    _obj_cls = ProjectRelease
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("tag_name",), optional=("name", "description", "ref", "assets")
+    )
+    _list_filters = ("order_by", "sort", "include_html_description")
+    _update_attrs = RequiredOptional(
+        optional=("name", "description", "milestones", "released_at")
+    )
+    _types = {"milestones": ArrayAttribute}
+
+
+class ProjectReleaseLink(ObjectDeleteMixin, SaveMixin, RESTObject):
+    pass
+
+
+class ProjectReleaseLinkManager(CRUDMixin[ProjectReleaseLink]):
+    _path = "/projects/{project_id}/releases/{tag_name}/assets/links"
+    _obj_cls = ProjectReleaseLink
+    _from_parent_attrs = {"project_id": "project_id", "tag_name": "tag_name"}
+    _create_attrs = RequiredOptional(
+        required=("name", "url"),
+        optional=("filepath", "direct_asset_path", "link_type"),
+    )
+    _update_attrs = RequiredOptional(
+        optional=("name", "url", "filepath", "direct_asset_path", "link_type")
+    )
diff --git a/gitlab/v4/objects/repositories.py b/gitlab/v4/objects/repositories.py
new file mode 100644
index 000000000..71935caaa
--- /dev/null
+++ b/gitlab/v4/objects/repositories.py
@@ -0,0 +1,367 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/repositories.html
+
+Currently this module only contains repository-related methods for projects.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Callable, Iterator, Literal, overload, TYPE_CHECKING
+
+import requests
+
+import gitlab
+from gitlab import cli
+from gitlab import exceptions as exc
+from gitlab import types, utils
+
+if TYPE_CHECKING:
+    # When running mypy we use these as the base classes
+    _RestObjectBase = gitlab.base.RESTObject
+else:
+    _RestObjectBase = object
+
+
+class RepositoryMixin(_RestObjectBase):
+    @cli.register_custom_action(
+        cls_names="Project", required=("submodule", "branch", "commit_sha")
+    )
+    @exc.on_http_error(exc.GitlabUpdateError)
+    def update_submodule(
+        self, submodule: str, branch: str, commit_sha: str, **kwargs: Any
+    ) -> dict[str, Any] | requests.Response:
+        """Update a project submodule
+
+        Args:
+            submodule: Full path to the submodule
+            branch: Name of the branch to commit into
+            commit_sha: Full commit SHA to update the submodule to
+            commit_message: Commit message. If no message is provided, a
+                default one will be set (optional)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabPutError: If the submodule could not be updated
+        """
+
+        submodule = utils.EncodedId(submodule)
+        path = f"/projects/{self.encoded_id}/repository/submodules/{submodule}"
+        data = {"branch": branch, "commit_sha": commit_sha}
+        if "commit_message" in kwargs:
+            data["commit_message"] = kwargs["commit_message"]
+        return self.manager.gitlab.http_put(path, post_data=data)
+
+    @cli.register_custom_action(
+        cls_names="Project", optional=("path", "ref", "recursive")
+    )
+    @exc.on_http_error(exc.GitlabGetError)
+    def repository_tree(
+        self, path: str = "", ref: str = "", recursive: bool = False, **kwargs: Any
+    ) -> gitlab.client.GitlabList | list[dict[str, Any]]:
+        """Return a list of files in the repository.
+
+        Args:
+            path: Path of the top folder (/ by default)
+            ref: Reference to a commit or branch
+            recursive: Whether to get the tree recursively
+            get_all: If True, return all the items, without pagination
+            per_page: Number of items to retrieve per request
+            page: ID of the page to return (starts with page 1)
+            iterator: If set to True and no pagination option is
+                defined, return a generator instead of a list
+            **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:
+            The representation of the tree
+        """
+        gl_path = f"/projects/{self.encoded_id}/repository/tree"
+        query_data: dict[str, Any] = {"recursive": recursive}
+        if path:
+            query_data["path"] = path
+        if ref:
+            query_data["ref"] = ref
+        return self.manager.gitlab.http_list(gl_path, query_data=query_data, **kwargs)
+
+    @cli.register_custom_action(cls_names="Project", required=("sha",))
+    @exc.on_http_error(exc.GitlabGetError)
+    def repository_blob(
+        self, sha: str, **kwargs: Any
+    ) -> dict[str, Any] | requests.Response:
+        """Return a file by blob SHA.
+
+        Args:
+            sha: ID of the blob
+            **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:
+            The blob content and metadata
+        """
+
+        path = f"/projects/{self.encoded_id}/repository/blobs/{sha}"
+        return self.manager.gitlab.http_get(path, **kwargs)
+
+    @overload
+    def repository_raw_blob(
+        self,
+        sha: str,
+        streamed: Literal[False] = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> bytes: ...
+
+    @overload
+    def repository_raw_blob(
+        self,
+        sha: str,
+        streamed: bool = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[True] = True,
+        **kwargs: Any,
+    ) -> Iterator[Any]: ...
+
+    @overload
+    def repository_raw_blob(
+        self,
+        sha: str,
+        streamed: Literal[True] = True,
+        action: Callable[[bytes], Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> None: ...
+
+    @cli.register_custom_action(cls_names="Project", required=("sha",))
+    @exc.on_http_error(exc.GitlabGetError)
+    def repository_raw_blob(
+        self,
+        sha: str,
+        streamed: bool = False,
+        action: Callable[..., Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: bool = False,
+        **kwargs: Any,
+    ) -> bytes | Iterator[Any] | None:
+        """Return the raw file contents for a blob.
+
+        Args:
+            sha: ID of the blob
+            streamed: If True the data will be processed by chunks of
+                `chunk_size` and each chunk is passed to `action` for
+                treatment
+            iterator: If True directly return the underlying response
+                iterator
+            action: Callable responsible of dealing with chunk of
+                data
+            chunk_size: 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:
+            The blob content if streamed is False, None otherwise
+        """
+        path = f"/projects/{self.encoded_id}/repository/blobs/{sha}/raw"
+        result = self.manager.gitlab.http_get(
+            path, streamed=streamed, raw=True, **kwargs
+        )
+        if TYPE_CHECKING:
+            assert isinstance(result, requests.Response)
+        return utils.response_content(
+            result, streamed, action, chunk_size, iterator=iterator
+        )
+
+    @cli.register_custom_action(cls_names="Project", required=("from_", "to"))
+    @exc.on_http_error(exc.GitlabGetError)
+    def repository_compare(
+        self, from_: str, to: str, **kwargs: Any
+    ) -> dict[str, Any] | requests.Response:
+        """Return a diff between two branches/commits.
+
+        Args:
+            from_: Source branch/SHA
+            to: Destination branch/SHA
+            **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:
+            The diff
+        """
+        path = f"/projects/{self.encoded_id}/repository/compare"
+        query_data = {"from": from_, "to": to}
+        return self.manager.gitlab.http_get(path, query_data=query_data, **kwargs)
+
+    @cli.register_custom_action(cls_names="Project")
+    @exc.on_http_error(exc.GitlabGetError)
+    def repository_contributors(
+        self, **kwargs: Any
+    ) -> gitlab.client.GitlabList | list[dict[str, Any]]:
+        """Return a list of contributors for the project.
+
+        Args:
+            get_all: If True, return all the items, without pagination
+            per_page: Number of items to retrieve per request
+            page: ID of the page to return (starts with page 1)
+            iterator: If set to True and no pagination option is
+                defined, return a generator instead of a list
+            **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:
+            The contributors
+        """
+        path = f"/projects/{self.encoded_id}/repository/contributors"
+        return self.manager.gitlab.http_list(path, **kwargs)
+
+    @overload
+    def repository_archive(
+        self,
+        sha: str | None = None,
+        streamed: Literal[False] = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> bytes: ...
+
+    @overload
+    def repository_archive(
+        self,
+        sha: str | None = None,
+        streamed: bool = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[True] = True,
+        **kwargs: Any,
+    ) -> Iterator[Any]: ...
+
+    @overload
+    def repository_archive(
+        self,
+        sha: str | None = None,
+        streamed: Literal[True] = True,
+        action: Callable[[bytes], Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> None: ...
+
+    @cli.register_custom_action(cls_names="Project", optional=("sha", "format"))
+    @exc.on_http_error(exc.GitlabListError)
+    def repository_archive(
+        self,
+        sha: str | None = None,
+        streamed: bool = False,
+        action: Callable[..., Any] | None = None,
+        chunk_size: int = 1024,
+        format: str | None = None,
+        path: str | None = None,
+        *,
+        iterator: bool = False,
+        **kwargs: Any,
+    ) -> bytes | Iterator[Any] | None:
+        """Return an archive of the repository.
+
+        Args:
+            sha: ID of the commit (default branch by default)
+            streamed: If True the data will be processed by chunks of
+                `chunk_size` and each chunk is passed to `action` for
+                treatment
+            iterator: If True directly return the underlying response
+                iterator
+            action: Callable responsible of dealing with chunk of
+                data
+            chunk_size: Size of each chunk
+            format: file format (tar.gz by default)
+            path: The subpath of the repository to download (all files by default)
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabListError: If the server failed to perform the request
+
+        Returns:
+            The binary data of the archive
+        """
+        url_path = f"/projects/{self.encoded_id}/repository/archive"
+        if format:
+            url_path += "." + format
+        query_data = {}
+        if sha:
+            query_data["sha"] = sha
+        if path is not None:
+            query_data["path"] = path
+        result = self.manager.gitlab.http_get(
+            url_path, query_data=query_data, raw=True, streamed=streamed, **kwargs
+        )
+        if TYPE_CHECKING:
+            assert isinstance(result, requests.Response)
+        return utils.response_content(
+            result, streamed, action, chunk_size, iterator=iterator
+        )
+
+    @cli.register_custom_action(cls_names="Project", required=("refs",))
+    @exc.on_http_error(exc.GitlabGetError)
+    def repository_merge_base(
+        self, refs: list[str], **kwargs: Any
+    ) -> dict[str, Any] | requests.Response:
+        """Return a diff between two branches/commits.
+
+        Args:
+            refs: The refs to find the common ancestor of. Multiple refs can be passed.
+            **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:
+            The common ancestor commit (*not* a RESTObject)
+        """
+        path = f"/projects/{self.encoded_id}/repository/merge_base"
+        query_data, _ = utils._transform_types(
+            data={"refs": refs},
+            custom_types={"refs": types.ArrayAttribute},
+            transform_data=True,
+        )
+        return self.manager.gitlab.http_get(path, query_data=query_data, **kwargs)
+
+    @cli.register_custom_action(cls_names="Project")
+    @exc.on_http_error(exc.GitlabDeleteError)
+    def delete_merged_branches(self, **kwargs: Any) -> None:
+        """Delete merged branches.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabDeleteError: If the server failed to perform the request
+        """
+        path = f"/projects/{self.encoded_id}/repository/merged_branches"
+        self.manager.gitlab.http_delete(path, **kwargs)
diff --git a/gitlab/v4/objects/resource_groups.py b/gitlab/v4/objects/resource_groups.py
new file mode 100644
index 000000000..6ff84eefc
--- /dev/null
+++ b/gitlab/v4/objects/resource_groups.py
@@ -0,0 +1,40 @@
+from __future__ import annotations
+
+from gitlab.base import RESTObject
+from gitlab.mixins import ListMixin, RetrieveMixin, SaveMixin, UpdateMixin
+from gitlab.types import RequiredOptional
+
+__all__ = [
+    "ProjectResourceGroup",
+    "ProjectResourceGroupManager",
+    "ProjectResourceGroupUpcomingJob",
+    "ProjectResourceGroupUpcomingJobManager",
+]
+
+
+class ProjectResourceGroup(SaveMixin, RESTObject):
+    _id_attr = "key"
+
+    upcoming_jobs: ProjectResourceGroupUpcomingJobManager
+
+
+class ProjectResourceGroupManager(
+    RetrieveMixin[ProjectResourceGroup], UpdateMixin[ProjectResourceGroup]
+):
+    _path = "/projects/{project_id}/resource_groups"
+    _obj_cls = ProjectResourceGroup
+    _from_parent_attrs = {"project_id": "id"}
+    _list_filters = ("order_by", "sort", "include_html_description")
+    _update_attrs = RequiredOptional(optional=("process_mode",))
+
+
+class ProjectResourceGroupUpcomingJob(RESTObject):
+    pass
+
+
+class ProjectResourceGroupUpcomingJobManager(
+    ListMixin[ProjectResourceGroupUpcomingJob]
+):
+    _path = "/projects/{project_id}/resource_groups/{resource_group_key}/upcoming_jobs"
+    _obj_cls = ProjectResourceGroupUpcomingJob
+    _from_parent_attrs = {"project_id": "project_id", "resource_group_key": "key"}
diff --git a/gitlab/v4/objects/reviewers.py b/gitlab/v4/objects/reviewers.py
new file mode 100644
index 000000000..95fcd143d
--- /dev/null
+++ b/gitlab/v4/objects/reviewers.py
@@ -0,0 +1,19 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import ListMixin
+
+__all__ = [
+    "ProjectMergeRequestReviewerDetail",
+    "ProjectMergeRequestReviewerDetailManager",
+]
+
+
+class ProjectMergeRequestReviewerDetail(RESTObject):
+    pass
+
+
+class ProjectMergeRequestReviewerDetailManager(
+    ListMixin[ProjectMergeRequestReviewerDetail]
+):
+    _path = "/projects/{project_id}/merge_requests/{mr_iid}/reviewers"
+    _obj_cls = ProjectMergeRequestReviewerDetail
+    _from_parent_attrs = {"project_id": "project_id", "mr_iid": "iid"}
diff --git a/gitlab/v4/objects/runners.py b/gitlab/v4/objects/runners.py
new file mode 100644
index 000000000..e4a37e8e3
--- /dev/null
+++ b/gitlab/v4/objects/runners.py
@@ -0,0 +1,162 @@
+from __future__ import annotations
+
+from typing import Any
+
+from gitlab import cli
+from gitlab import exceptions as exc
+from gitlab import types
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    CreateMixin,
+    CRUDMixin,
+    DeleteMixin,
+    ListMixin,
+    ObjectDeleteMixin,
+    SaveMixin,
+)
+from gitlab.types import RequiredOptional
+
+__all__ = [
+    "RunnerJob",
+    "RunnerJobManager",
+    "Runner",
+    "RunnerManager",
+    "RunnerAll",
+    "RunnerAllManager",
+    "GroupRunner",
+    "GroupRunnerManager",
+    "ProjectRunner",
+    "ProjectRunnerManager",
+]
+
+
+class RunnerJob(RESTObject):
+    pass
+
+
+class RunnerJobManager(ListMixin[RunnerJob]):
+    _path = "/runners/{runner_id}/jobs"
+    _obj_cls = RunnerJob
+    _from_parent_attrs = {"runner_id": "id"}
+    _list_filters = ("status",)
+
+
+class Runner(SaveMixin, ObjectDeleteMixin, RESTObject):
+    jobs: RunnerJobManager
+    _repr_attr = "description"
+
+
+class RunnerManager(CRUDMixin[Runner]):
+    _path = "/runners"
+    _obj_cls = Runner
+    _create_attrs = RequiredOptional(
+        required=("token",),
+        optional=(
+            "description",
+            "info",
+            "active",
+            "locked",
+            "run_untagged",
+            "tag_list",
+            "access_level",
+            "maximum_timeout",
+        ),
+    )
+    _update_attrs = RequiredOptional(
+        optional=(
+            "description",
+            "active",
+            "tag_list",
+            "run_untagged",
+            "locked",
+            "access_level",
+            "maximum_timeout",
+        )
+    )
+    _list_filters = ("scope", "type", "status", "paused", "tag_list")
+    _types = {"tag_list": types.CommaSeparatedListAttribute}
+
+    @cli.register_custom_action(cls_names="RunnerManager", optional=("scope",))
+    @exc.on_http_error(exc.GitlabListError)
+    def all(self, scope: str | None = None, **kwargs: Any) -> list[Runner]:
+        """List all the runners.
+
+        Args:
+            scope: The scope of runners to show, one of: specific,
+                shared, active, paused, online
+            get_all: If True, return all the items, without pagination
+            per_page: Number of items to retrieve per request
+            page: ID of the page to return (starts with page 1)
+            iterator: If set to True and no pagination option is
+                defined, return a generator instead of a list
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabListError: If the server failed to perform the request
+
+        Returns:
+            A list of runners matching the scope.
+        """
+        path = "/runners/all"
+        query_data = {}
+        if scope is not None:
+            query_data["scope"] = scope
+        obj = self.gitlab.http_list(path, query_data, **kwargs)
+        return [self._obj_cls(self, item) for item in obj]
+
+    @cli.register_custom_action(cls_names="RunnerManager", required=("token",))
+    @exc.on_http_error(exc.GitlabVerifyError)
+    def verify(self, token: str, **kwargs: Any) -> None:
+        """Validates authentication credentials for a registered Runner.
+
+        Args:
+            token: The runner's authentication token
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabVerifyError: If the server failed to verify the token
+        """
+        path = "/runners/verify"
+        post_data = {"token": token}
+        self.gitlab.http_post(path, post_data=post_data, **kwargs)
+
+
+class RunnerAll(RESTObject):
+    _repr_attr = "description"
+
+
+class RunnerAllManager(ListMixin[RunnerAll]):
+    _path = "/runners/all"
+    _obj_cls = RunnerAll
+    _list_filters = ("scope", "type", "status", "paused", "tag_list")
+    _types = {"tag_list": types.CommaSeparatedListAttribute}
+
+
+class GroupRunner(RESTObject):
+    pass
+
+
+class GroupRunnerManager(ListMixin[GroupRunner]):
+    _path = "/groups/{group_id}/runners"
+    _obj_cls = GroupRunner
+    _from_parent_attrs = {"group_id": "id"}
+    _create_attrs = RequiredOptional(required=("runner_id",))
+    _list_filters = ("scope", "tag_list")
+    _types = {"tag_list": types.CommaSeparatedListAttribute}
+
+
+class ProjectRunner(ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class ProjectRunnerManager(
+    CreateMixin[ProjectRunner], DeleteMixin[ProjectRunner], ListMixin[ProjectRunner]
+):
+    _path = "/projects/{project_id}/runners"
+    _obj_cls = ProjectRunner
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(required=("runner_id",))
+    _list_filters = ("scope", "tag_list")
+    _types = {"tag_list": types.CommaSeparatedListAttribute}
diff --git a/gitlab/v4/objects/secure_files.py b/gitlab/v4/objects/secure_files.py
new file mode 100644
index 000000000..5db517f21
--- /dev/null
+++ b/gitlab/v4/objects/secure_files.py
@@ -0,0 +1,102 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/secure_files.html
+"""
+
+from __future__ import annotations
+
+from typing import Any, Callable, Iterator, Literal, overload, TYPE_CHECKING
+
+import requests
+
+from gitlab import cli
+from gitlab import exceptions as exc
+from gitlab import utils
+from gitlab.base import RESTObject
+from gitlab.mixins import NoUpdateMixin, ObjectDeleteMixin
+from gitlab.types import FileAttribute, RequiredOptional
+
+__all__ = ["ProjectSecureFile", "ProjectSecureFileManager"]
+
+
+class ProjectSecureFile(ObjectDeleteMixin, RESTObject):
+    @overload
+    def download(
+        self,
+        streamed: Literal[False] = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> bytes: ...
+
+    @overload
+    def download(
+        self,
+        streamed: bool = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[True] = True,
+        **kwargs: Any,
+    ) -> Iterator[Any]: ...
+
+    @overload
+    def download(
+        self,
+        streamed: Literal[True] = True,
+        action: Callable[[bytes], Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> None: ...
+
+    @cli.register_custom_action(cls_names="ProjectSecureFile")
+    @exc.on_http_error(exc.GitlabGetError)
+    def download(
+        self,
+        streamed: bool = False,
+        action: Callable[[bytes], Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: bool = False,
+        **kwargs: Any,
+    ) -> bytes | Iterator[Any] | None:
+        """Download the secure file.
+
+        Args:
+            streamed: If True the data will be processed by chunks of
+                `chunk_size` and each chunk is passed to `action` for
+                treatment
+            iterator: If True directly return the underlying response
+                iterator
+            action: Callable responsible of dealing with chunk of
+                data
+            chunk_size: 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 artifacts could not be retrieved
+
+        Returns:
+            The artifacts if `streamed` is False, None otherwise."""
+        path = f"{self.manager.path}/{self.id}/download"
+        result = self.manager.gitlab.http_get(
+            path, streamed=streamed, raw=True, **kwargs
+        )
+        if TYPE_CHECKING:
+            assert isinstance(result, requests.Response)
+        return utils.response_content(
+            result, streamed, action, chunk_size, iterator=iterator
+        )
+
+
+class ProjectSecureFileManager(NoUpdateMixin[ProjectSecureFile]):
+    _path = "/projects/{project_id}/secure_files"
+    _obj_cls = ProjectSecureFile
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(required=("name", "file"))
+    _types = {"file": FileAttribute}
diff --git a/gitlab/v4/objects/service_accounts.py b/gitlab/v4/objects/service_accounts.py
new file mode 100644
index 000000000..bf6f53d4f
--- /dev/null
+++ b/gitlab/v4/objects/service_accounts.py
@@ -0,0 +1,20 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import CreateMixin, DeleteMixin, ListMixin, ObjectDeleteMixin
+from gitlab.types import RequiredOptional
+
+__all__ = ["GroupServiceAccount", "GroupServiceAccountManager"]
+
+
+class GroupServiceAccount(ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class GroupServiceAccountManager(
+    CreateMixin[GroupServiceAccount],
+    DeleteMixin[GroupServiceAccount],
+    ListMixin[GroupServiceAccount],
+):
+    _path = "/groups/{group_id}/service_accounts"
+    _obj_cls = GroupServiceAccount
+    _from_parent_attrs = {"group_id": "id"}
+    _create_attrs = RequiredOptional(optional=("name", "username"))
diff --git a/gitlab/v4/objects/settings.py b/gitlab/v4/objects/settings.py
new file mode 100644
index 000000000..fd8629b36
--- /dev/null
+++ b/gitlab/v4/objects/settings.py
@@ -0,0 +1,119 @@
+from __future__ import annotations
+
+from typing import Any
+
+from gitlab import exceptions as exc
+from gitlab import types
+from gitlab.base import RESTObject
+from gitlab.mixins import GetWithoutIdMixin, SaveMixin, UpdateMixin
+from gitlab.types import RequiredOptional
+
+__all__ = ["ApplicationSettings", "ApplicationSettingsManager"]
+
+
+class ApplicationSettings(SaveMixin, RESTObject):
+    _id_attr = None
+
+
+class ApplicationSettingsManager(
+    GetWithoutIdMixin[ApplicationSettings], UpdateMixin[ApplicationSettings]
+):
+    _path = "/application/settings"
+    _obj_cls = ApplicationSettings
+    _update_attrs = RequiredOptional(
+        optional=(
+            "id",
+            "default_projects_limit",
+            "signup_enabled",
+            "silent_mode_enabled",
+            "password_authentication_enabled_for_web",
+            "gravatar_enabled",
+            "sign_in_text",
+            "created_at",
+            "updated_at",
+            "home_page_url",
+            "default_branch_protection",
+            "restricted_visibility_levels",
+            "max_attachment_size",
+            "session_expire_delay",
+            "default_project_visibility",
+            "default_snippet_visibility",
+            "default_group_visibility",
+            "outbound_local_requests_whitelist",
+            "disabled_oauth_sign_in_sources",
+            "domain_whitelist",
+            "domain_blacklist_enabled",
+            "domain_blacklist",
+            "domain_allowlist",
+            "domain_denylist_enabled",
+            "domain_denylist",
+            "external_authorization_service_enabled",
+            "external_authorization_service_url",
+            "external_authorization_service_default_label",
+            "external_authorization_service_timeout",
+            "import_sources",
+            "user_oauth_applications",
+            "after_sign_out_path",
+            "container_registry_token_expire_delay",
+            "repository_storages",
+            "plantuml_enabled",
+            "plantuml_url",
+            "terminal_max_session_time",
+            "polling_interval_multiplier",
+            "rsa_key_restriction",
+            "dsa_key_restriction",
+            "ecdsa_key_restriction",
+            "ed25519_key_restriction",
+            "first_day_of_week",
+            "enforce_terms",
+            "terms",
+            "performance_bar_allowed_group_id",
+            "instance_statistics_visibility_private",
+            "user_show_add_ssh_key_message",
+            "file_template_project_id",
+            "local_markdown_version",
+            "asset_proxy_enabled",
+            "asset_proxy_url",
+            "asset_proxy_whitelist",
+            "asset_proxy_allowlist",
+            "geo_node_allowed_ips",
+            "allow_local_requests_from_hooks_and_services",
+            "allow_local_requests_from_web_hooks_and_services",
+            "allow_local_requests_from_system_hooks",
+        )
+    )
+    _types = {
+        "asset_proxy_allowlist": types.ArrayAttribute,
+        "disabled_oauth_sign_in_sources": types.ArrayAttribute,
+        "domain_allowlist": types.ArrayAttribute,
+        "domain_denylist": types.ArrayAttribute,
+        "import_sources": types.ArrayAttribute,
+        "restricted_visibility_levels": types.ArrayAttribute,
+    }
+
+    @exc.on_http_error(exc.GitlabUpdateError)
+    def update(
+        self,
+        id: str | int | None = None,
+        new_data: dict[str, Any] | None = None,
+        **kwargs: Any,
+    ) -> dict[str, Any]:
+        """Update an object on the server.
+
+        Args:
+            id: ID of the object to update (can be None if not required)
+            new_data: the update data for the object
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Returns:
+            The new object data (*not* a RESTObject)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabUpdateError: If the server cannot perform the request
+        """
+        new_data = new_data or {}
+        data = new_data.copy()
+        if "domain_whitelist" in data and data["domain_whitelist"] is None:
+            data.pop("domain_whitelist")
+        return super().update(id, data, **kwargs)
diff --git a/gitlab/v4/objects/sidekiq.py b/gitlab/v4/objects/sidekiq.py
new file mode 100644
index 000000000..5a5eff7d4
--- /dev/null
+++ b/gitlab/v4/objects/sidekiq.py
@@ -0,0 +1,90 @@
+from __future__ import annotations
+
+from typing import Any
+
+import requests
+
+from gitlab import cli
+from gitlab import exceptions as exc
+from gitlab.base import RESTManager, RESTObject
+
+__all__ = ["SidekiqManager"]
+
+
+class SidekiqManager(RESTManager[RESTObject]):
+    """Manager for the Sidekiq methods.
+
+    This manager doesn't actually manage objects but provides helper function
+    for the sidekiq metrics API.
+    """
+
+    _path = "/sidekiq"
+    _obj_cls = RESTObject
+
+    @cli.register_custom_action(cls_names="SidekiqManager")
+    @exc.on_http_error(exc.GitlabGetError)
+    def queue_metrics(self, **kwargs: Any) -> dict[str, Any] | requests.Response:
+        """Return the registered queues information.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabGetError: If the information couldn't be retrieved
+
+        Returns:
+            Information about the Sidekiq queues
+        """
+        return self.gitlab.http_get(f"{self.path}/queue_metrics", **kwargs)
+
+    @cli.register_custom_action(cls_names="SidekiqManager")
+    @exc.on_http_error(exc.GitlabGetError)
+    def process_metrics(self, **kwargs: Any) -> dict[str, Any] | requests.Response:
+        """Return the registered sidekiq workers.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabGetError: If the information couldn't be retrieved
+
+        Returns:
+            Information about the register Sidekiq worker
+        """
+        return self.gitlab.http_get(f"{self.path}/process_metrics", **kwargs)
+
+    @cli.register_custom_action(cls_names="SidekiqManager")
+    @exc.on_http_error(exc.GitlabGetError)
+    def job_stats(self, **kwargs: Any) -> dict[str, Any] | requests.Response:
+        """Return statistics about the jobs performed.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabGetError: If the information couldn't be retrieved
+
+        Returns:
+            Statistics about the Sidekiq jobs performed
+        """
+        return self.gitlab.http_get(f"{self.path}/job_stats", **kwargs)
+
+    @cli.register_custom_action(cls_names="SidekiqManager")
+    @exc.on_http_error(exc.GitlabGetError)
+    def compound_metrics(self, **kwargs: Any) -> dict[str, Any] | requests.Response:
+        """Return all available metrics and statistics.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabGetError: If the information couldn't be retrieved
+
+        Returns:
+            All available Sidekiq metrics and statistics
+        """
+        return self.gitlab.http_get(f"{self.path}/compound_metrics", **kwargs)
diff --git a/gitlab/v4/objects/snippets.py b/gitlab/v4/objects/snippets.py
new file mode 100644
index 000000000..b6e136131
--- /dev/null
+++ b/gitlab/v4/objects/snippets.py
@@ -0,0 +1,327 @@
+from __future__ import annotations
+
+from typing import Any, Callable, Iterator, Literal, overload, TYPE_CHECKING
+
+import requests
+
+from gitlab import cli
+from gitlab import exceptions as exc
+from gitlab import utils
+from gitlab.base import RESTObject, RESTObjectList
+from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin, UserAgentDetailMixin
+from gitlab.types import RequiredOptional
+
+from .award_emojis import ProjectSnippetAwardEmojiManager  # noqa: F401
+from .discussions import ProjectSnippetDiscussionManager  # noqa: F401
+from .notes import ProjectSnippetNoteManager  # noqa: F401
+
+__all__ = ["Snippet", "SnippetManager", "ProjectSnippet", "ProjectSnippetManager"]
+
+
+class Snippet(UserAgentDetailMixin, SaveMixin, ObjectDeleteMixin, RESTObject):
+    _repr_attr = "title"
+
+    @overload
+    def content(
+        self,
+        streamed: Literal[False] = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> bytes: ...
+
+    @overload
+    def content(
+        self,
+        streamed: bool = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[True] = True,
+        **kwargs: Any,
+    ) -> Iterator[Any]: ...
+
+    @overload
+    def content(
+        self,
+        streamed: Literal[True] = True,
+        action: Callable[[bytes], Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> None: ...
+
+    @cli.register_custom_action(cls_names="Snippet")
+    @exc.on_http_error(exc.GitlabGetError)
+    def content(
+        self,
+        streamed: bool = False,
+        action: Callable[..., Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: bool = False,
+        **kwargs: Any,
+    ) -> bytes | Iterator[Any] | None:
+        """Return the content of a snippet.
+
+        Args:
+            streamed: If True the data will be processed by chunks of
+                `chunk_size` and each chunk is passed to `action` for
+                treatment.
+            iterator: If True directly return the underlying response
+                iterator
+            action: Callable responsible of dealing with chunk of
+                data
+            chunk_size: 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 content could not be retrieved
+
+        Returns:
+            The snippet content
+        """
+        path = f"/snippets/{self.encoded_id}/raw"
+        result = self.manager.gitlab.http_get(
+            path, streamed=streamed, raw=True, **kwargs
+        )
+        if TYPE_CHECKING:
+            assert isinstance(result, requests.Response)
+        return utils.response_content(
+            result, streamed, action, chunk_size, iterator=iterator
+        )
+
+
+class SnippetManager(CRUDMixin[Snippet]):
+    _path = "/snippets"
+    _obj_cls = Snippet
+    _create_attrs = RequiredOptional(
+        required=("title",),
+        exclusive=("files", "file_name"),
+        optional=("description", "content", "visibility"),
+    )
+    _update_attrs = RequiredOptional(
+        optional=("title", "files", "file_name", "content", "visibility", "description")
+    )
+
+    @overload
+    def list_public(
+        self, *, iterator: Literal[False] = False, **kwargs: Any
+    ) -> list[Snippet]: ...
+
+    @overload
+    def list_public(
+        self, *, iterator: Literal[True] = True, **kwargs: Any
+    ) -> RESTObjectList[Snippet]: ...
+
+    @overload
+    def list_public(
+        self, *, iterator: bool = False, **kwargs: Any
+    ) -> RESTObjectList[Snippet] | list[Snippet]: ...
+
+    @cli.register_custom_action(cls_names="SnippetManager")
+    def list_public(
+        self, *, iterator: bool = False, **kwargs: Any
+    ) -> RESTObjectList[Snippet] | list[Snippet]:
+        """List all public snippets.
+
+        Args:
+            get_all: If True, return all the items, without pagination
+            per_page: Number of items to retrieve per request
+            page: ID of the page to return (starts with page 1)
+            iterator: If set to True and no pagination option is
+                defined, return a generator instead of a list
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabListError: If the list could not be retrieved
+
+        Returns:
+            The list of snippets, or a generator if `iterator` is True
+        """
+        return self.list(path="/snippets/public", iterator=iterator, **kwargs)
+
+    @overload
+    def list_all(
+        self, *, iterator: Literal[False] = False, **kwargs: Any
+    ) -> list[Snippet]: ...
+
+    @overload
+    def list_all(
+        self, *, iterator: Literal[True] = True, **kwargs: Any
+    ) -> RESTObjectList[Snippet]: ...
+
+    @overload
+    def list_all(
+        self, *, iterator: bool = False, **kwargs: Any
+    ) -> RESTObjectList[Snippet] | list[Snippet]: ...
+
+    @cli.register_custom_action(cls_names="SnippetManager")
+    def list_all(
+        self, *, iterator: bool = False, **kwargs: Any
+    ) -> RESTObjectList[Snippet] | list[Snippet]:
+        """List all snippets.
+
+        Args:
+            get_all: If True, return all the items, without pagination
+            per_page: Number of items to retrieve per request
+            page: ID of the page to return (starts with page 1)
+            iterator: If set to True and no pagination option is
+                defined, return a generator instead of a list
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabListError: If the list could not be retrieved
+
+        Returns:
+            A generator for the snippets list
+        """
+        return self.list(path="/snippets/all", iterator=iterator, **kwargs)
+
+    @overload
+    def public(
+        self,
+        *,
+        iterator: Literal[False] = False,
+        page: int | None = None,
+        **kwargs: Any,
+    ) -> list[Snippet]: ...
+
+    @overload
+    def public(
+        self, *, iterator: Literal[True] = True, **kwargs: Any
+    ) -> RESTObjectList[Snippet]: ...
+
+    @overload
+    def public(
+        self, *, iterator: bool = False, **kwargs: Any
+    ) -> RESTObjectList[Snippet] | list[Snippet]: ...
+
+    def public(
+        self, *, iterator: bool = False, **kwargs: Any
+    ) -> RESTObjectList[Snippet] | list[Snippet]:
+        """List all public snippets.
+
+        Args:
+            get_all: If True, return all the items, without pagination
+            per_page: Number of items to retrieve per request
+            page: ID of the page to return (starts with page 1)
+            iterator: If set to True and no pagination option is
+                defined, return a generator instead of a list
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabListError: If the list could not be retrieved
+
+        Returns:
+            The list of snippets, or a generator if `iterator` is True
+        """
+        utils.warn(
+            message=(
+                "Gitlab.snippets.public() is deprecated and will be removed in a "
+                "future major version. Use Gitlab.snippets.list_public() instead."
+            ),
+            category=DeprecationWarning,
+        )
+        return self.list(path="/snippets/public", iterator=iterator, **kwargs)
+
+
+class ProjectSnippet(UserAgentDetailMixin, SaveMixin, ObjectDeleteMixin, RESTObject):
+    _url = "/projects/{project_id}/snippets"
+    _repr_attr = "title"
+
+    awardemojis: ProjectSnippetAwardEmojiManager
+    discussions: ProjectSnippetDiscussionManager
+    notes: ProjectSnippetNoteManager
+
+    @overload
+    def content(
+        self,
+        streamed: Literal[False] = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> bytes: ...
+
+    @overload
+    def content(
+        self,
+        streamed: bool = False,
+        action: None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[True] = True,
+        **kwargs: Any,
+    ) -> Iterator[Any]: ...
+
+    @overload
+    def content(
+        self,
+        streamed: Literal[True] = True,
+        action: Callable[[bytes], Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: Literal[False] = False,
+        **kwargs: Any,
+    ) -> None: ...
+
+    @cli.register_custom_action(cls_names="ProjectSnippet")
+    @exc.on_http_error(exc.GitlabGetError)
+    def content(
+        self,
+        streamed: bool = False,
+        action: Callable[..., Any] | None = None,
+        chunk_size: int = 1024,
+        *,
+        iterator: bool = False,
+        **kwargs: Any,
+    ) -> bytes | Iterator[Any] | None:
+        """Return the content of a snippet.
+
+        Args:
+            streamed: If True the data will be processed by chunks of
+                `chunk_size` and each chunk is passed to `action` for
+                treatment.
+            iterator: If True directly return the underlying response
+                iterator
+            action: Callable responsible of dealing with chunk of
+                data
+            chunk_size: 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 content could not be retrieved
+
+        Returns:
+            The snippet content
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/raw"
+        result = self.manager.gitlab.http_get(
+            path, streamed=streamed, raw=True, **kwargs
+        )
+        if TYPE_CHECKING:
+            assert isinstance(result, requests.Response)
+        return utils.response_content(
+            result, streamed, action, chunk_size, iterator=iterator
+        )
+
+
+class ProjectSnippetManager(CRUDMixin[ProjectSnippet]):
+    _path = "/projects/{project_id}/snippets"
+    _obj_cls = ProjectSnippet
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("title", "visibility"),
+        exclusive=("files", "file_name"),
+        optional=("description", "content"),
+    )
+    _update_attrs = RequiredOptional(
+        optional=("title", "files", "file_name", "content", "visibility", "description")
+    )
diff --git a/gitlab/v4/objects/statistics.py b/gitlab/v4/objects/statistics.py
new file mode 100644
index 000000000..4a3033f9b
--- /dev/null
+++ b/gitlab/v4/objects/statistics.py
@@ -0,0 +1,72 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import GetWithoutIdMixin, RefreshMixin
+from gitlab.types import ArrayAttribute
+
+__all__ = [
+    "GroupIssuesStatistics",
+    "GroupIssuesStatisticsManager",
+    "ProjectAdditionalStatistics",
+    "ProjectAdditionalStatisticsManager",
+    "IssuesStatistics",
+    "IssuesStatisticsManager",
+    "ProjectIssuesStatistics",
+    "ProjectIssuesStatisticsManager",
+    "ApplicationStatistics",
+    "ApplicationStatisticsManager",
+]
+
+
+class ProjectAdditionalStatistics(RefreshMixin, RESTObject):
+    _id_attr = None
+
+
+class ProjectAdditionalStatisticsManager(
+    GetWithoutIdMixin[ProjectAdditionalStatistics]
+):
+    _path = "/projects/{project_id}/statistics"
+    _obj_cls = ProjectAdditionalStatistics
+    _from_parent_attrs = {"project_id": "id"}
+
+
+class IssuesStatistics(RefreshMixin, RESTObject):
+    _id_attr = None
+
+
+class IssuesStatisticsManager(GetWithoutIdMixin[IssuesStatistics]):
+    _path = "/issues_statistics"
+    _obj_cls = IssuesStatistics
+    _list_filters = ("iids",)
+    _types = {"iids": ArrayAttribute}
+
+
+class GroupIssuesStatistics(RefreshMixin, RESTObject):
+    _id_attr = None
+
+
+class GroupIssuesStatisticsManager(GetWithoutIdMixin[GroupIssuesStatistics]):
+    _path = "/groups/{group_id}/issues_statistics"
+    _obj_cls = GroupIssuesStatistics
+    _from_parent_attrs = {"group_id": "id"}
+    _list_filters = ("iids",)
+    _types = {"iids": ArrayAttribute}
+
+
+class ProjectIssuesStatistics(RefreshMixin, RESTObject):
+    _id_attr = None
+
+
+class ProjectIssuesStatisticsManager(GetWithoutIdMixin[ProjectIssuesStatistics]):
+    _path = "/projects/{project_id}/issues_statistics"
+    _obj_cls = ProjectIssuesStatistics
+    _from_parent_attrs = {"project_id": "id"}
+    _list_filters = ("iids",)
+    _types = {"iids": ArrayAttribute}
+
+
+class ApplicationStatistics(RESTObject):
+    _id_attr = None
+
+
+class ApplicationStatisticsManager(GetWithoutIdMixin[ApplicationStatistics]):
+    _path = "/application/statistics"
+    _obj_cls = ApplicationStatistics
diff --git a/gitlab/v4/objects/status_checks.py b/gitlab/v4/objects/status_checks.py
new file mode 100644
index 000000000..e54b7444e
--- /dev/null
+++ b/gitlab/v4/objects/status_checks.py
@@ -0,0 +1,55 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import (
+    CreateMixin,
+    DeleteMixin,
+    ListMixin,
+    ObjectDeleteMixin,
+    SaveMixin,
+    UpdateMethod,
+    UpdateMixin,
+)
+from gitlab.types import ArrayAttribute, RequiredOptional
+
+__all__ = [
+    "ProjectExternalStatusCheck",
+    "ProjectExternalStatusCheckManager",
+    "ProjectMergeRequestStatusCheck",
+    "ProjectMergeRequestStatusCheckManager",
+]
+
+
+class ProjectExternalStatusCheck(SaveMixin, ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class ProjectExternalStatusCheckManager(
+    ListMixin[ProjectExternalStatusCheck],
+    CreateMixin[ProjectExternalStatusCheck],
+    UpdateMixin[ProjectExternalStatusCheck],
+    DeleteMixin[ProjectExternalStatusCheck],
+):
+    _path = "/projects/{project_id}/external_status_checks"
+    _obj_cls = ProjectExternalStatusCheck
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("name", "external_url"),
+        optional=("shared_secret", "protected_branch_ids"),
+    )
+    _update_attrs = RequiredOptional(
+        optional=("name", "external_url", "shared_secret", "protected_branch_ids")
+    )
+    _types = {"protected_branch_ids": ArrayAttribute}
+
+
+class ProjectMergeRequestStatusCheck(SaveMixin, RESTObject):
+    pass
+
+
+class ProjectMergeRequestStatusCheckManager(ListMixin[ProjectMergeRequestStatusCheck]):
+    _path = "/projects/{project_id}/merge_requests/{merge_request_iid}/status_checks"
+    _obj_cls = ProjectMergeRequestStatusCheck
+    _from_parent_attrs = {"project_id": "project_id", "merge_request_iid": "iid"}
+    _update_attrs = RequiredOptional(
+        required=("sha", "external_status_check_id", "status")
+    )
+    _update_method = UpdateMethod.POST
diff --git a/gitlab/v4/objects/tags.py b/gitlab/v4/objects/tags.py
new file mode 100644
index 000000000..ad04b4928
--- /dev/null
+++ b/gitlab/v4/objects/tags.py
@@ -0,0 +1,39 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import NoUpdateMixin, ObjectDeleteMixin
+from gitlab.types import RequiredOptional
+
+__all__ = [
+    "ProjectTag",
+    "ProjectTagManager",
+    "ProjectProtectedTag",
+    "ProjectProtectedTagManager",
+]
+
+
+class ProjectTag(ObjectDeleteMixin, RESTObject):
+    _id_attr = "name"
+    _repr_attr = "name"
+
+
+class ProjectTagManager(NoUpdateMixin[ProjectTag]):
+    _path = "/projects/{project_id}/repository/tags"
+    _obj_cls = ProjectTag
+    _from_parent_attrs = {"project_id": "id"}
+    _list_filters = ("order_by", "sort", "search")
+    _create_attrs = RequiredOptional(
+        required=("tag_name", "ref"), optional=("message",)
+    )
+
+
+class ProjectProtectedTag(ObjectDeleteMixin, RESTObject):
+    _id_attr = "name"
+    _repr_attr = "name"
+
+
+class ProjectProtectedTagManager(NoUpdateMixin[ProjectProtectedTag]):
+    _path = "/projects/{project_id}/protected_tags"
+    _obj_cls = ProjectProtectedTag
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("name",), optional=("create_access_level",)
+    )
diff --git a/gitlab/v4/objects/templates.py b/gitlab/v4/objects/templates.py
new file mode 100644
index 000000000..d96e9a1e4
--- /dev/null
+++ b/gitlab/v4/objects/templates.py
@@ -0,0 +1,123 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import RetrieveMixin
+
+__all__ = [
+    "Dockerfile",
+    "DockerfileManager",
+    "Gitignore",
+    "GitignoreManager",
+    "Gitlabciyml",
+    "GitlabciymlManager",
+    "License",
+    "LicenseManager",
+    "ProjectDockerfileTemplate",
+    "ProjectDockerfileTemplateManager",
+    "ProjectGitignoreTemplate",
+    "ProjectGitignoreTemplateManager",
+    "ProjectGitlabciymlTemplate",
+    "ProjectGitlabciymlTemplateManager",
+    "ProjectIssueTemplate",
+    "ProjectIssueTemplateManager",
+    "ProjectLicenseTemplate",
+    "ProjectLicenseTemplateManager",
+    "ProjectMergeRequestTemplate",
+    "ProjectMergeRequestTemplateManager",
+]
+
+
+class Dockerfile(RESTObject):
+    _id_attr = "name"
+
+
+class DockerfileManager(RetrieveMixin[Dockerfile]):
+    _path = "/templates/dockerfiles"
+    _obj_cls = Dockerfile
+
+
+class Gitignore(RESTObject):
+    _id_attr = "name"
+
+
+class GitignoreManager(RetrieveMixin[Gitignore]):
+    _path = "/templates/gitignores"
+    _obj_cls = Gitignore
+
+
+class Gitlabciyml(RESTObject):
+    _id_attr = "name"
+
+
+class GitlabciymlManager(RetrieveMixin[Gitlabciyml]):
+    _path = "/templates/gitlab_ci_ymls"
+    _obj_cls = Gitlabciyml
+
+
+class License(RESTObject):
+    _id_attr = "key"
+
+
+class LicenseManager(RetrieveMixin[License]):
+    _path = "/templates/licenses"
+    _obj_cls = License
+    _list_filters = ("popular",)
+    _optional_get_attrs = ("project", "fullname")
+
+
+class ProjectDockerfileTemplate(RESTObject):
+    _id_attr = "name"
+
+
+class ProjectDockerfileTemplateManager(RetrieveMixin[ProjectDockerfileTemplate]):
+    _path = "/projects/{project_id}/templates/dockerfiles"
+    _obj_cls = ProjectDockerfileTemplate
+    _from_parent_attrs = {"project_id": "id"}
+
+
+class ProjectGitignoreTemplate(RESTObject):
+    _id_attr = "name"
+
+
+class ProjectGitignoreTemplateManager(RetrieveMixin[ProjectGitignoreTemplate]):
+    _path = "/projects/{project_id}/templates/gitignores"
+    _obj_cls = ProjectGitignoreTemplate
+    _from_parent_attrs = {"project_id": "id"}
+
+
+class ProjectGitlabciymlTemplate(RESTObject):
+    _id_attr = "name"
+
+
+class ProjectGitlabciymlTemplateManager(RetrieveMixin[ProjectGitlabciymlTemplate]):
+    _path = "/projects/{project_id}/templates/gitlab_ci_ymls"
+    _obj_cls = ProjectGitlabciymlTemplate
+    _from_parent_attrs = {"project_id": "id"}
+
+
+class ProjectLicenseTemplate(RESTObject):
+    _id_attr = "key"
+
+
+class ProjectLicenseTemplateManager(RetrieveMixin[ProjectLicenseTemplate]):
+    _path = "/projects/{project_id}/templates/licenses"
+    _obj_cls = ProjectLicenseTemplate
+    _from_parent_attrs = {"project_id": "id"}
+
+
+class ProjectIssueTemplate(RESTObject):
+    _id_attr = "name"
+
+
+class ProjectIssueTemplateManager(RetrieveMixin[ProjectIssueTemplate]):
+    _path = "/projects/{project_id}/templates/issues"
+    _obj_cls = ProjectIssueTemplate
+    _from_parent_attrs = {"project_id": "id"}
+
+
+class ProjectMergeRequestTemplate(RESTObject):
+    _id_attr = "name"
+
+
+class ProjectMergeRequestTemplateManager(RetrieveMixin[ProjectMergeRequestTemplate]):
+    _path = "/projects/{project_id}/templates/merge_requests"
+    _obj_cls = ProjectMergeRequestTemplate
+    _from_parent_attrs = {"project_id": "id"}
diff --git a/gitlab/v4/objects/todos.py b/gitlab/v4/objects/todos.py
new file mode 100644
index 000000000..4758d4da2
--- /dev/null
+++ b/gitlab/v4/objects/todos.py
@@ -0,0 +1,55 @@
+from typing import Any, Dict, TYPE_CHECKING
+
+from gitlab import cli
+from gitlab import exceptions as exc
+from gitlab.base import RESTObject
+from gitlab.mixins import DeleteMixin, ListMixin, ObjectDeleteMixin
+
+__all__ = ["Todo", "TodoManager"]
+
+
+class Todo(ObjectDeleteMixin, RESTObject):
+    @cli.register_custom_action(cls_names="Todo")
+    @exc.on_http_error(exc.GitlabTodoError)
+    def mark_as_done(self, **kwargs: Any) -> Dict[str, Any]:
+        """Mark the todo as done.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabTodoError: If the server failed to perform the request
+
+        Returns:
+            A dict with the result
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/mark_as_done"
+        server_data = self.manager.gitlab.http_post(path, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+        self._update_attrs(server_data)
+        return server_data
+
+
+class TodoManager(ListMixin[Todo], DeleteMixin[Todo]):
+    _path = "/todos"
+    _obj_cls = Todo
+    _list_filters = ("action", "author_id", "project_id", "state", "type")
+
+    @cli.register_custom_action(cls_names="TodoManager")
+    @exc.on_http_error(exc.GitlabTodoError)
+    def mark_all_as_done(self, **kwargs: Any) -> None:
+        """Mark all the todos as done.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabTodoError: If the server failed to perform the request
+
+        Returns:
+            The number of todos marked done
+        """
+        self.gitlab.http_post("/todos/mark_as_done", **kwargs)
diff --git a/gitlab/v4/objects/topics.py b/gitlab/v4/objects/topics.py
new file mode 100644
index 000000000..09ca570bb
--- /dev/null
+++ b/gitlab/v4/objects/topics.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+from typing import Any, TYPE_CHECKING
+
+from gitlab import cli
+from gitlab import exceptions as exc
+from gitlab import types
+from gitlab.base import RESTObject
+from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin
+from gitlab.types import RequiredOptional
+
+__all__ = ["Topic", "TopicManager"]
+
+
+class Topic(SaveMixin, ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class TopicManager(CRUDMixin[Topic]):
+    _path = "/topics"
+    _obj_cls = Topic
+    _create_attrs = RequiredOptional(
+        # NOTE: The `title` field was added and is required in GitLab 15.0 or
+        # newer. But not present before that.
+        required=("name",),
+        optional=("avatar", "description", "title"),
+    )
+    _update_attrs = RequiredOptional(optional=("avatar", "description", "name"))
+    _types = {"avatar": types.ImageAttribute}
+
+    @cli.register_custom_action(
+        cls_names="TopicManager", required=("source_topic_id", "target_topic_id")
+    )
+    @exc.on_http_error(exc.GitlabMRClosedError)
+    def merge(
+        self, source_topic_id: int | str, target_topic_id: int | str, **kwargs: Any
+    ) -> dict[str, Any]:
+        """Merge two topics, assigning all projects to the target topic.
+
+        Args:
+            source_topic_id: ID of source project topic
+            target_topic_id: ID of target project topic
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabTopicMergeError: If the merge failed
+
+        Returns:
+            The merged topic data (*not* a RESTObject)
+        """
+        path = f"{self.path}/merge"
+        data = {"source_topic_id": source_topic_id, "target_topic_id": target_topic_id}
+
+        server_data = self.gitlab.http_post(path, post_data=data, **kwargs)
+        if TYPE_CHECKING:
+            assert isinstance(server_data, dict)
+        return server_data
diff --git a/gitlab/v4/objects/triggers.py b/gitlab/v4/objects/triggers.py
new file mode 100644
index 000000000..363146395
--- /dev/null
+++ b/gitlab/v4/objects/triggers.py
@@ -0,0 +1,17 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin
+from gitlab.types import RequiredOptional
+
+__all__ = ["ProjectTrigger", "ProjectTriggerManager"]
+
+
+class ProjectTrigger(SaveMixin, ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class ProjectTriggerManager(CRUDMixin[ProjectTrigger]):
+    _path = "/projects/{project_id}/triggers"
+    _obj_cls = ProjectTrigger
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(required=("description",))
+    _update_attrs = RequiredOptional(required=("description",))
diff --git a/gitlab/v4/objects/users.py b/gitlab/v4/objects/users.py
new file mode 100644
index 000000000..dec0b375d
--- /dev/null
+++ b/gitlab/v4/objects/users.py
@@ -0,0 +1,717 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/users.html
+https://docs.gitlab.com/ee/api/projects.html#list-projects-starred-by-a-user
+"""
+
+from __future__ import annotations
+
+from typing import Any, cast, Literal, Optional, overload
+
+import requests
+
+from gitlab import cli
+from gitlab import exceptions as exc
+from gitlab import types
+from gitlab.base import RESTObject, RESTObjectList
+from gitlab.mixins import (
+    CreateMixin,
+    CRUDMixin,
+    DeleteMixin,
+    GetWithoutIdMixin,
+    ListMixin,
+    NoUpdateMixin,
+    ObjectDeleteMixin,
+    RetrieveMixin,
+    SaveMixin,
+    UpdateMixin,
+)
+from gitlab.types import ArrayAttribute, RequiredOptional
+
+from .custom_attributes import UserCustomAttributeManager  # noqa: F401
+from .events import UserEventManager  # noqa: F401
+from .personal_access_tokens import UserPersonalAccessTokenManager  # noqa: F401
+
+__all__ = [
+    "CurrentUserEmail",
+    "CurrentUserEmailManager",
+    "CurrentUserGPGKey",
+    "CurrentUserGPGKeyManager",
+    "CurrentUserKey",
+    "CurrentUserKeyManager",
+    "CurrentUserRunner",
+    "CurrentUserRunnerManager",
+    "CurrentUserStatus",
+    "CurrentUserStatusManager",
+    "CurrentUser",
+    "CurrentUserManager",
+    "User",
+    "UserManager",
+    "ProjectUser",
+    "ProjectUserManager",
+    "StarredProject",
+    "StarredProjectManager",
+    "UserEmail",
+    "UserEmailManager",
+    "UserActivities",
+    "UserStatus",
+    "UserStatusManager",
+    "UserActivitiesManager",
+    "UserGPGKey",
+    "UserGPGKeyManager",
+    "UserKey",
+    "UserKeyManager",
+    "UserIdentityProviderManager",
+    "UserImpersonationToken",
+    "UserImpersonationTokenManager",
+    "UserMembership",
+    "UserMembershipManager",
+    "UserProject",
+    "UserProjectManager",
+    "UserContributedProject",
+    "UserContributedProjectManager",
+]
+
+
+class CurrentUserEmail(ObjectDeleteMixin, RESTObject):
+    _repr_attr = "email"
+
+
+class CurrentUserEmailManager(
+    RetrieveMixin[CurrentUserEmail],
+    CreateMixin[CurrentUserEmail],
+    DeleteMixin[CurrentUserEmail],
+):
+    _path = "/user/emails"
+    _obj_cls = CurrentUserEmail
+    _create_attrs = RequiredOptional(required=("email",))
+
+
+class CurrentUserGPGKey(ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class CurrentUserGPGKeyManager(
+    RetrieveMixin[CurrentUserGPGKey],
+    CreateMixin[CurrentUserGPGKey],
+    DeleteMixin[CurrentUserGPGKey],
+):
+    _path = "/user/gpg_keys"
+    _obj_cls = CurrentUserGPGKey
+    _create_attrs = RequiredOptional(required=("key",))
+
+
+class CurrentUserKey(ObjectDeleteMixin, RESTObject):
+    _repr_attr = "title"
+
+
+class CurrentUserKeyManager(
+    RetrieveMixin[CurrentUserKey],
+    CreateMixin[CurrentUserKey],
+    DeleteMixin[CurrentUserKey],
+):
+    _path = "/user/keys"
+    _obj_cls = CurrentUserKey
+    _create_attrs = RequiredOptional(required=("title", "key"))
+
+
+class CurrentUserRunner(RESTObject):
+    pass
+
+
+class CurrentUserRunnerManager(CreateMixin[CurrentUserRunner]):
+    _path = "/user/runners"
+    _obj_cls = CurrentUserRunner
+    _types = {"tag_list": types.CommaSeparatedListAttribute}
+    _create_attrs = RequiredOptional(
+        required=("runner_type",),
+        optional=(
+            "group_id",
+            "project_id",
+            "description",
+            "paused",
+            "locked",
+            "run_untagged",
+            "tag_list",
+            "access_level",
+            "maximum_timeout",
+            "maintenance_note",
+        ),
+    )
+
+
+class CurrentUserStatus(SaveMixin, RESTObject):
+    _id_attr = None
+    _repr_attr = "message"
+
+
+class CurrentUserStatusManager(
+    GetWithoutIdMixin[CurrentUserStatus], UpdateMixin[CurrentUserStatus]
+):
+    _path = "/user/status"
+    _obj_cls = CurrentUserStatus
+    _update_attrs = RequiredOptional(optional=("emoji", "message"))
+
+
+class CurrentUser(RESTObject):
+    _id_attr = None
+    _repr_attr = "username"
+
+    emails: CurrentUserEmailManager
+    gpgkeys: CurrentUserGPGKeyManager
+    keys: CurrentUserKeyManager
+    runners: CurrentUserRunnerManager
+    status: CurrentUserStatusManager
+
+
+class CurrentUserManager(GetWithoutIdMixin[CurrentUser]):
+    _path = "/user"
+    _obj_cls = CurrentUser
+
+
+class User(SaveMixin, ObjectDeleteMixin, RESTObject):
+    _repr_attr = "username"
+
+    customattributes: UserCustomAttributeManager
+    emails: UserEmailManager
+    events: UserEventManager
+    followers_users: UserFollowersManager
+    following_users: UserFollowingManager
+    gpgkeys: UserGPGKeyManager
+    identityproviders: UserIdentityProviderManager
+    impersonationtokens: UserImpersonationTokenManager
+    keys: UserKeyManager
+    memberships: UserMembershipManager
+    personal_access_tokens: UserPersonalAccessTokenManager
+    projects: UserProjectManager
+    contributed_projects: UserContributedProjectManager
+    starred_projects: StarredProjectManager
+    status: UserStatusManager
+
+    @cli.register_custom_action(cls_names="User")
+    @exc.on_http_error(exc.GitlabBlockError)
+    def block(self, **kwargs: Any) -> bool | None:
+        """Block the user.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabBlockError: If the user could not be blocked
+
+        Returns:
+            Whether the user status has been changed
+        """
+        path = f"/users/{self.encoded_id}/block"
+        # NOTE: Undocumented behavior of the GitLab API is that it returns a
+        # boolean or None
+        server_data = cast(
+            Optional[bool], self.manager.gitlab.http_post(path, **kwargs)
+        )
+        if server_data is True:
+            self._attrs["state"] = "blocked"
+        return server_data
+
+    @cli.register_custom_action(cls_names="User")
+    @exc.on_http_error(exc.GitlabFollowError)
+    def follow(self, **kwargs: Any) -> dict[str, Any] | requests.Response:
+        """Follow the user.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabFollowError: If the user could not be followed
+
+        Returns:
+            The new object data (*not* a RESTObject)
+        """
+        path = f"/users/{self.encoded_id}/follow"
+        return self.manager.gitlab.http_post(path, **kwargs)
+
+    @cli.register_custom_action(cls_names="User")
+    @exc.on_http_error(exc.GitlabUnfollowError)
+    def unfollow(self, **kwargs: Any) -> dict[str, Any] | requests.Response:
+        """Unfollow the user.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabUnfollowError: If the user could not be followed
+
+        Returns:
+            The new object data (*not* a RESTObject)
+        """
+        path = f"/users/{self.encoded_id}/unfollow"
+        return self.manager.gitlab.http_post(path, **kwargs)
+
+    @cli.register_custom_action(cls_names="User")
+    @exc.on_http_error(exc.GitlabUnblockError)
+    def unblock(self, **kwargs: Any) -> bool | None:
+        """Unblock the user.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabUnblockError: If the user could not be unblocked
+
+        Returns:
+            Whether the user status has been changed
+        """
+        path = f"/users/{self.encoded_id}/unblock"
+        # NOTE: Undocumented behavior of the GitLab API is that it returns a
+        # boolean or None
+        server_data = cast(
+            Optional[bool], self.manager.gitlab.http_post(path, **kwargs)
+        )
+        if server_data is True:
+            self._attrs["state"] = "active"
+        return server_data
+
+    @cli.register_custom_action(cls_names="User")
+    @exc.on_http_error(exc.GitlabDeactivateError)
+    def deactivate(self, **kwargs: Any) -> dict[str, Any] | requests.Response:
+        """Deactivate the user.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabDeactivateError: If the user could not be deactivated
+
+        Returns:
+            Whether the user status has been changed
+        """
+        path = f"/users/{self.encoded_id}/deactivate"
+        server_data = self.manager.gitlab.http_post(path, **kwargs)
+        if server_data:
+            self._attrs["state"] = "deactivated"
+        return server_data
+
+    @cli.register_custom_action(cls_names="User")
+    @exc.on_http_error(exc.GitlabActivateError)
+    def activate(self, **kwargs: Any) -> dict[str, Any] | requests.Response:
+        """Activate the user.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabActivateError: If the user could not be activated
+
+        Returns:
+            Whether the user status has been changed
+        """
+        path = f"/users/{self.encoded_id}/activate"
+        server_data = self.manager.gitlab.http_post(path, **kwargs)
+        if server_data:
+            self._attrs["state"] = "active"
+        return server_data
+
+    @cli.register_custom_action(cls_names="User")
+    @exc.on_http_error(exc.GitlabUserApproveError)
+    def approve(self, **kwargs: Any) -> dict[str, Any] | requests.Response:
+        """Approve a user creation request.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabUserApproveError: If the user could not be activated
+
+        Returns:
+            The new object data (*not* a RESTObject)
+        """
+        path = f"/users/{self.encoded_id}/approve"
+        return self.manager.gitlab.http_post(path, **kwargs)
+
+    @cli.register_custom_action(cls_names="User")
+    @exc.on_http_error(exc.GitlabUserRejectError)
+    def reject(self, **kwargs: Any) -> dict[str, Any] | requests.Response:
+        """Reject a user creation request.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabUserRejectError: If the user could not be rejected
+
+        Returns:
+            The new object data (*not* a RESTObject)
+        """
+        path = f"/users/{self.encoded_id}/reject"
+        return self.manager.gitlab.http_post(path, **kwargs)
+
+    @cli.register_custom_action(cls_names="User")
+    @exc.on_http_error(exc.GitlabBanError)
+    def ban(self, **kwargs: Any) -> dict[str, Any] | requests.Response:
+        """Ban the user.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabBanError: If the user could not be banned
+
+        Returns:
+            Whether the user has been banned
+        """
+        path = f"/users/{self.encoded_id}/ban"
+        server_data = self.manager.gitlab.http_post(path, **kwargs)
+        if server_data:
+            self._attrs["state"] = "banned"
+        return server_data
+
+    @cli.register_custom_action(cls_names="User")
+    @exc.on_http_error(exc.GitlabUnbanError)
+    def unban(self, **kwargs: Any) -> dict[str, Any] | requests.Response:
+        """Unban the user.
+
+        Args:
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabUnbanError: If the user could not be unbanned
+
+        Returns:
+            Whether the user has been unbanned
+        """
+        path = f"/users/{self.encoded_id}/unban"
+        server_data = self.manager.gitlab.http_post(path, **kwargs)
+        if server_data:
+            self._attrs["state"] = "active"
+        return server_data
+
+
+class UserManager(CRUDMixin[User]):
+    _path = "/users"
+    _obj_cls = User
+
+    _list_filters = (
+        "active",
+        "blocked",
+        "username",
+        "extern_uid",
+        "provider",
+        "external",
+        "search",
+        "custom_attributes",
+        "status",
+        "two_factor",
+    )
+    _create_attrs = RequiredOptional(
+        optional=(
+            "email",
+            "username",
+            "name",
+            "password",
+            "reset_password",
+            "skype",
+            "linkedin",
+            "twitter",
+            "projects_limit",
+            "extern_uid",
+            "provider",
+            "bio",
+            "admin",
+            "can_create_group",
+            "website_url",
+            "skip_confirmation",
+            "external",
+            "organization",
+            "location",
+            "avatar",
+            "public_email",
+            "private_profile",
+            "color_scheme_id",
+            "theme_id",
+        )
+    )
+    _update_attrs = RequiredOptional(
+        required=("email", "username", "name"),
+        optional=(
+            "password",
+            "skype",
+            "linkedin",
+            "twitter",
+            "projects_limit",
+            "extern_uid",
+            "provider",
+            "bio",
+            "admin",
+            "can_create_group",
+            "website_url",
+            "skip_reconfirmation",
+            "external",
+            "organization",
+            "location",
+            "avatar",
+            "public_email",
+            "private_profile",
+            "color_scheme_id",
+            "theme_id",
+        ),
+    )
+    _types = {"confirm": types.LowercaseStringAttribute, "avatar": types.ImageAttribute}
+
+
+class ProjectUser(RESTObject):
+    pass
+
+
+class ProjectUserManager(ListMixin[ProjectUser]):
+    _path = "/projects/{project_id}/users"
+    _obj_cls = ProjectUser
+    _from_parent_attrs = {"project_id": "id"}
+    _list_filters = ("search", "skip_users")
+    _types = {"skip_users": types.ArrayAttribute}
+
+
+class UserEmail(ObjectDeleteMixin, RESTObject):
+    _repr_attr = "email"
+
+
+class UserEmailManager(
+    RetrieveMixin[UserEmail], CreateMixin[UserEmail], DeleteMixin[UserEmail]
+):
+    _path = "/users/{user_id}/emails"
+    _obj_cls = UserEmail
+    _from_parent_attrs = {"user_id": "id"}
+    _create_attrs = RequiredOptional(required=("email",))
+
+
+class UserActivities(RESTObject):
+    _id_attr = "username"
+
+
+class UserStatus(RESTObject):
+    _id_attr = None
+    _repr_attr = "message"
+
+
+class UserStatusManager(GetWithoutIdMixin[UserStatus]):
+    _path = "/users/{user_id}/status"
+    _obj_cls = UserStatus
+    _from_parent_attrs = {"user_id": "id"}
+
+
+class UserActivitiesManager(ListMixin[UserActivities]):
+    _path = "/user/activities"
+    _obj_cls = UserActivities
+
+
+class UserGPGKey(ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class UserGPGKeyManager(
+    RetrieveMixin[UserGPGKey], CreateMixin[UserGPGKey], DeleteMixin[UserGPGKey]
+):
+    _path = "/users/{user_id}/gpg_keys"
+    _obj_cls = UserGPGKey
+    _from_parent_attrs = {"user_id": "id"}
+    _create_attrs = RequiredOptional(required=("key",))
+
+
+class UserKey(ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class UserKeyManager(
+    RetrieveMixin[UserKey], CreateMixin[UserKey], DeleteMixin[UserKey]
+):
+    _path = "/users/{user_id}/keys"
+    _obj_cls = UserKey
+    _from_parent_attrs = {"user_id": "id"}
+    _create_attrs = RequiredOptional(required=("title", "key"))
+
+
+class UserIdentityProviderManager(DeleteMixin[User]):
+    """Manager for user identities.
+
+    This manager does not actually manage objects but enables
+    functionality for deletion of user identities by provider.
+    """
+
+    _path = "/users/{user_id}/identities"
+    _obj_cls = User
+    _from_parent_attrs = {"user_id": "id"}
+
+
+class UserImpersonationToken(ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class UserImpersonationTokenManager(NoUpdateMixin[UserImpersonationToken]):
+    _path = "/users/{user_id}/impersonation_tokens"
+    _obj_cls = UserImpersonationToken
+    _from_parent_attrs = {"user_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("name", "scopes"), optional=("expires_at",)
+    )
+    _list_filters = ("state",)
+    _types = {"scopes": ArrayAttribute}
+
+
+class UserMembership(RESTObject):
+    _id_attr = "source_id"
+
+
+class UserMembershipManager(RetrieveMixin[UserMembership]):
+    _path = "/users/{user_id}/memberships"
+    _obj_cls = UserMembership
+    _from_parent_attrs = {"user_id": "id"}
+    _list_filters = ("type",)
+
+
+# Having this outside projects avoids circular imports due to ProjectUser
+class UserProject(RESTObject):
+    pass
+
+
+class UserProjectManager(ListMixin[UserProject], CreateMixin[UserProject]):
+    _path = "/projects/user/{user_id}"
+    _obj_cls = UserProject
+    _from_parent_attrs = {"user_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("name",),
+        optional=(
+            "default_branch",
+            "issues_enabled",
+            "wall_enabled",
+            "merge_requests_enabled",
+            "wiki_enabled",
+            "snippets_enabled",
+            "squash_option",
+            "public",
+            "visibility",
+            "description",
+            "builds_enabled",
+            "public_builds",
+            "import_url",
+            "only_allow_merge_if_build_succeeds",
+        ),
+    )
+    _list_filters = (
+        "archived",
+        "visibility",
+        "order_by",
+        "sort",
+        "search",
+        "simple",
+        "owned",
+        "membership",
+        "starred",
+        "statistics",
+        "with_issues_enabled",
+        "with_merge_requests_enabled",
+        "with_custom_attributes",
+        "with_programming_language",
+        "wiki_checksum_failed",
+        "repository_checksum_failed",
+        "min_access_level",
+        "id_after",
+        "id_before",
+    )
+
+    @overload
+    def list(
+        self, *, iterator: Literal[False] = False, **kwargs: Any
+    ) -> list[UserProject]: ...
+
+    @overload
+    def list(
+        self, *, iterator: Literal[True] = True, **kwargs: Any
+    ) -> RESTObjectList[UserProject]: ...
+
+    @overload
+    def list(
+        self, *, iterator: bool = False, **kwargs: Any
+    ) -> RESTObjectList[UserProject] | list[UserProject]: ...
+
+    def list(
+        self, *, iterator: bool = False, **kwargs: Any
+    ) -> RESTObjectList[UserProject] | list[UserProject]:
+        """Retrieve a list of objects.
+
+        Args:
+            get_all: If True, return all the items, without pagination
+            per_page: Number of items to retrieve per request
+            page: ID of the page to return (starts with page 1)
+            iterator: If set to True and no pagination option is
+                defined, return a generator instead of a list
+            **kwargs: Extra options to send to the server (e.g. sudo)
+
+        Returns:
+            The list of objects, or a generator if `iterator` is True
+
+        Raises:
+            GitlabAuthenticationError: If authentication is not correct
+            GitlabListError: If the server cannot perform the request
+        """
+        if self._parent:
+            path = f"/users/{self._parent.id}/projects"
+        else:
+            path = f"/users/{self._from_parent_attrs['user_id']}/projects"
+        return super().list(path=path, iterator=iterator, **kwargs)
+
+
+class UserContributedProject(RESTObject):
+    _id_attr = "id"
+    _repr_attr = "path_with_namespace"
+
+
+class UserContributedProjectManager(ListMixin[UserContributedProject]):
+    _path = "/users/{user_id}/contributed_projects"
+    _obj_cls = UserContributedProject
+    _from_parent_attrs = {"user_id": "id"}
+
+
+class StarredProject(RESTObject):
+    pass
+
+
+class StarredProjectManager(ListMixin[StarredProject]):
+    _path = "/users/{user_id}/starred_projects"
+    _obj_cls = StarredProject
+    _from_parent_attrs = {"user_id": "id"}
+    _list_filters = (
+        "archived",
+        "membership",
+        "min_access_level",
+        "order_by",
+        "owned",
+        "search",
+        "simple",
+        "sort",
+        "starred",
+        "statistics",
+        "visibility",
+        "with_custom_attributes",
+        "with_issues_enabled",
+        "with_merge_requests_enabled",
+    )
+
+
+class UserFollowersManager(ListMixin[User]):
+    _path = "/users/{user_id}/followers"
+    _obj_cls = User
+    _from_parent_attrs = {"user_id": "id"}
+
+
+class UserFollowingManager(ListMixin[User]):
+    _path = "/users/{user_id}/following"
+    _obj_cls = User
+    _from_parent_attrs = {"user_id": "id"}
diff --git a/gitlab/v4/objects/variables.py b/gitlab/v4/objects/variables.py
new file mode 100644
index 000000000..bae2be22b
--- /dev/null
+++ b/gitlab/v4/objects/variables.py
@@ -0,0 +1,68 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/instance_level_ci_variables.html
+https://docs.gitlab.com/ee/api/project_level_variables.html
+https://docs.gitlab.com/ee/api/group_level_variables.html
+"""
+
+from gitlab.base import RESTObject
+from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin
+from gitlab.types import RequiredOptional
+
+__all__ = [
+    "Variable",
+    "VariableManager",
+    "GroupVariable",
+    "GroupVariableManager",
+    "ProjectVariable",
+    "ProjectVariableManager",
+]
+
+
+class Variable(SaveMixin, ObjectDeleteMixin, RESTObject):
+    _id_attr = "key"
+
+
+class VariableManager(CRUDMixin[Variable]):
+    _path = "/admin/ci/variables"
+    _obj_cls = Variable
+    _create_attrs = RequiredOptional(
+        required=("key", "value"), optional=("protected", "variable_type", "masked")
+    )
+    _update_attrs = RequiredOptional(
+        required=("key", "value"), optional=("protected", "variable_type", "masked")
+    )
+
+
+class GroupVariable(SaveMixin, ObjectDeleteMixin, RESTObject):
+    _id_attr = "key"
+
+
+class GroupVariableManager(CRUDMixin[GroupVariable]):
+    _path = "/groups/{group_id}/variables"
+    _obj_cls = GroupVariable
+    _from_parent_attrs = {"group_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("key", "value"), optional=("protected", "variable_type", "masked")
+    )
+    _update_attrs = RequiredOptional(
+        required=("key", "value"), optional=("protected", "variable_type", "masked")
+    )
+
+
+class ProjectVariable(SaveMixin, ObjectDeleteMixin, RESTObject):
+    _id_attr = "key"
+
+
+class ProjectVariableManager(CRUDMixin[ProjectVariable]):
+    _path = "/projects/{project_id}/variables"
+    _obj_cls = ProjectVariable
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("key", "value"),
+        optional=("protected", "variable_type", "masked", "environment_scope"),
+    )
+    _update_attrs = RequiredOptional(
+        required=("key", "value"),
+        optional=("protected", "variable_type", "masked", "environment_scope"),
+    )
diff --git a/gitlab/v4/objects/wikis.py b/gitlab/v4/objects/wikis.py
new file mode 100644
index 000000000..21d023b34
--- /dev/null
+++ b/gitlab/v4/objects/wikis.py
@@ -0,0 +1,39 @@
+from gitlab.base import RESTObject
+from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin, UploadMixin
+from gitlab.types import RequiredOptional
+
+__all__ = ["ProjectWiki", "ProjectWikiManager", "GroupWiki", "GroupWikiManager"]
+
+
+class ProjectWiki(SaveMixin, ObjectDeleteMixin, UploadMixin, RESTObject):
+    _id_attr = "slug"
+    _repr_attr = "slug"
+    _upload_path = "/projects/{project_id}/wikis/attachments"
+
+
+class ProjectWikiManager(CRUDMixin[ProjectWiki]):
+    _path = "/projects/{project_id}/wikis"
+    _obj_cls = ProjectWiki
+    _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("title", "content"), optional=("format",)
+    )
+    _update_attrs = RequiredOptional(optional=("title", "content", "format"))
+    _list_filters = ("with_content",)
+
+
+class GroupWiki(SaveMixin, ObjectDeleteMixin, UploadMixin, RESTObject):
+    _id_attr = "slug"
+    _repr_attr = "slug"
+    _upload_path = "/groups/{group_id}/wikis/attachments"
+
+
+class GroupWikiManager(CRUDMixin[GroupWiki]):
+    _path = "/groups/{group_id}/wikis"
+    _obj_cls = GroupWiki
+    _from_parent_attrs = {"group_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("title", "content"), optional=("format",)
+    )
+    _update_attrs = RequiredOptional(optional=("title", "content", "format"))
+    _list_filters = ("with_content",)
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 000000000..5104c2b16
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,148 @@
+[build-system]
+requires = ["setuptools>=61.0.0"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "python-gitlab"
+description="The python wrapper for the GitLab REST and GraphQL APIs."
+readme = "README.rst"
+authors = [
+    {name = "Gauvain Pocentek", email= "gauvain@pocentek.net"}
+]
+maintainers = [
+    {name = "John Villalovos", email="john@sodarock.com"},
+    {name = "Max Wittig", email="max.wittig@siemens.com"},
+    {name = "Nejc Habjan", email="nejc.habjan@siemens.com"},
+    {name = "Roger Meier", email="r.meier@siemens.com"}
+]
+requires-python = ">=3.9.0"
+dependencies = [
+    "requests>=2.32.0",
+    "requests-toolbelt>=1.0.0",
+]
+classifiers = [
+    "Development Status :: 5 - Production/Stable",
+    "Environment :: Console",
+    "Intended Audience :: System Administrators",
+    "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
+    "Natural Language :: English",
+    "Operating System :: POSIX",
+    "Operating System :: Microsoft :: Windows",
+    "Programming Language :: Python",
+    "Programming Language :: Python :: 3",
+    "Programming Language :: Python :: 3.9",
+    "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
+    "Programming Language :: Python :: 3.12",
+    "Programming Language :: Python :: 3.13",
+]
+keywords = ["api", "client", "gitlab", "python", "python-gitlab", "wrapper"]
+license = {text = "LGPL-3.0-or-later"}
+dynamic = ["version"]
+
+[project.optional-dependencies]
+autocompletion = ["argcomplete>=1.10.0,<3"]
+yaml = ["PyYaml>=6.0.1"]
+graphql = ["gql[httpx]>=3.5.0,<4"]
+
+[project.scripts]
+gitlab = "gitlab.cli:main"
+
+[project.urls]
+Homepage = "https://github.com/python-gitlab/python-gitlab"
+Changelog = "https://github.com/python-gitlab/python-gitlab/blob/main/CHANGELOG.md"
+Documentation = "https://python-gitlab.readthedocs.io"
+Source = "https://github.com/python-gitlab/python-gitlab"
+
+[tool.setuptools.packages.find]
+exclude = ["docs*", "tests*"]
+
+[tool.setuptools.dynamic]
+version = { attr = "gitlab._version.__version__" }
+
+[tool.isort]
+profile = "black"
+multi_line_output = 3
+order_by_type = false
+
+[tool.mypy]
+files = "."
+exclude = "build/.*"
+strict = true
+
+[tool.black]
+skip_magic_trailing_comma = true
+
+# Overrides for currently untyped modules
+[[tool.mypy.overrides]]
+module = [
+    "docs.*",
+    "docs.ext.*",
+    "tests.unit.*",
+]
+ignore_errors = true
+
+[[tool.mypy.overrides]]
+module = [
+    "tests.functional.*",
+    "tests.functional.api.*",
+    "tests.smoke.*",
+]
+disable_error_code = ["no-untyped-def"]
+
+[tool.semantic_release]
+branch = "main"
+build_command = """
+    python -m pip install build~=0.10.0
+    python -m build .
+"""
+version_variables = [
+    "gitlab/_version.py:__version__",
+]
+commit_message = "chore: release v{version}"
+
+[tool.pylint.messages_control]
+max-line-length = 88
+jobs = 0  # Use auto-detected number of multiple processes to speed up Pylint.
+# TODO(jlvilla): Work on removing these disables over time.
+disable = [
+    "arguments-differ",
+    "arguments-renamed",
+    "broad-except",
+    "cyclic-import",
+    "duplicate-code",
+    "import-outside-toplevel",
+    "invalid-name",
+    "missing-class-docstring",
+    "missing-function-docstring",
+    "missing-module-docstring",
+    "not-callable",
+    "protected-access",
+    "redefined-builtin",
+    "signature-differs",
+    "too-few-public-methods",
+    "too-many-ancestors",
+    "too-many-arguments",
+    "too-many-branches",
+    "too-many-instance-attributes",
+    "too-many-lines",
+    "too-many-locals",
+    "too-many-positional-arguments",
+    "too-many-public-methods",
+    "too-many-statements",
+    "unsubscriptable-object",
+]
+
+[tool.pytest.ini_options]
+xfail_strict = true
+markers = [
+    "gitlab_premium: marks tests that require GitLab Premium",
+    "gitlab_ultimate: marks tests that require GitLab Ultimate",
+]
+
+# If 'log_cli=True' the following apply
+# NOTE: If set 'log_cli_level' to 'DEBUG' will show a log of all of the HTTP requests
+# made in functional tests.
+log_cli_level = "INFO"
+log_cli_format = "%(asctime)s.%(msecs)03d [%(levelname)8s] (%(filename)s:%(funcName)s:L%(lineno)s) %(message)s"
+log_cli_date_format = "%Y-%m-%d %H:%M:%S"
diff --git a/requirements-docker.txt b/requirements-docker.txt
new file mode 100644
index 000000000..532609b3f
--- /dev/null
+++ b/requirements-docker.txt
@@ -0,0 +1,3 @@
+-r requirements.txt
+-r requirements-test.txt
+pytest-docker==3.2.3
diff --git a/requirements-docs.txt b/requirements-docs.txt
new file mode 100644
index 000000000..39f5f61e2
--- /dev/null
+++ b/requirements-docs.txt
@@ -0,0 +1,7 @@
+-r requirements.txt
+furo==2025.7.19
+jinja2==3.1.6
+myst-parser==4.0.1
+sphinx==8.2.3
+sphinxcontrib-autoprogram==0.1.9
+sphinx-autobuild==2024.10.3
diff --git a/requirements-lint.txt b/requirements-lint.txt
new file mode 100644
index 000000000..73eb2fda0
--- /dev/null
+++ b/requirements-lint.txt
@@ -0,0 +1,14 @@
+-r requirements.txt
+argcomplete==2.0.0
+black==25.1.0
+commitizen==4.8.3
+flake8==7.3.0
+isort==6.0.1
+mypy==1.17.0
+pylint==3.3.7
+pytest==8.4.1
+responses==0.25.7
+respx==0.22.0
+types-PyYAML==6.0.12.20250516
+types-requests==2.32.4.20250611
+types-setuptools==80.9.0.20250529
diff --git a/requirements-precommit.txt b/requirements-precommit.txt
new file mode 100644
index 000000000..d5c247795
--- /dev/null
+++ b/requirements-precommit.txt
@@ -0,0 +1 @@
+pre-commit==4.2.0
diff --git a/requirements-test.txt b/requirements-test.txt
new file mode 100644
index 000000000..26d3b35af
--- /dev/null
+++ b/requirements-test.txt
@@ -0,0 +1,13 @@
+-r requirements.txt
+anyio==4.9.0
+build==1.2.2.post1
+coverage==7.9.2
+pytest-console-scripts==1.4.1
+pytest-cov==6.2.1
+pytest-github-actions-annotate-failures==0.3.0
+pytest==8.4.1
+PyYaml==6.0.2
+responses==0.25.7
+respx==0.22.0
+trio==0.30.0
+wheel==0.45.1
diff --git a/requirements.txt b/requirements.txt
index af8843719..7941900de 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,4 @@
-requests>1.0
-six
+gql==3.5.3
+httpx==0.28.1
+requests==2.32.4
+requests-toolbelt==1.0.0
diff --git a/rtd-requirements.txt b/rtd-requirements.txt
deleted file mode 100644
index 967d53a29..000000000
--- a/rtd-requirements.txt
+++ /dev/null
@@ -1,3 +0,0 @@
--r requirements.txt
-jinja2
-sphinx>=1.3
diff --git a/setup.py b/setup.py
deleted file mode 100644
index bbbe042d1..000000000
--- a/setup.py
+++ /dev/null
@@ -1,39 +0,0 @@
-#!/usr/bin/python
-# -*- coding: utf-8 -*-
-
-from setuptools import setup
-from setuptools import find_packages
-
-
-def get_version():
-    with open('gitlab/__init__.py') as f:
-        for line in f:
-            if line.startswith('__version__'):
-                return eval(line.split('=')[-1])
-
-
-setup(name='python-gitlab',
-      version=get_version(),
-      description='Interact with GitLab API',
-      long_description='Interact with GitLab API',
-      author='Gauvain Pocentek',
-      author_email='gauvain@pocentek.net',
-      license='LGPLv3',
-      url='https://github.com/gpocentek/python-gitlab',
-      packages=find_packages(),
-      install_requires=['requests>=1.0', 'six'],
-      entry_points={
-          'console_scripts': [
-              'gitlab = gitlab.cli:main'
-          ]
-      },
-      classifiers=[
-        'Development Status :: 5 - Production/Stable',
-        'Environment :: Console',
-        'Intended Audience :: System Administrators',
-        'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)',
-        'Natural Language :: English',
-        'Operating System :: POSIX',
-        'Operating System :: Microsoft :: Windows'
-        ]
-      )
diff --git a/test-requirements.txt b/test-requirements.txt
deleted file mode 100644
index 65d09d7d3..000000000
--- a/test-requirements.txt
+++ /dev/null
@@ -1,9 +0,0 @@
-coverage
-discover
-testrepository
-hacking>=0.9.2,<0.10
-httmock
-jinja2
-mock
-sphinx>=1.3
-sphinx_rtd_theme
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 000000000..de15d0a6c
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,40 @@
+import pathlib
+
+import _pytest.config
+import pytest
+
+import gitlab
+
+
+@pytest.fixture(scope="session")
+def test_dir(pytestconfig: _pytest.config.Config) -> pathlib.Path:
+    return pytestconfig.rootdir / "tests"  # type: ignore
+
+
+@pytest.fixture(autouse=True)
+def mock_clean_config(monkeypatch: pytest.MonkeyPatch) -> None:
+    """Ensures user-defined environment variables do not interfere with tests."""
+    monkeypatch.delenv("PYTHON_GITLAB_CFG", raising=False)
+    monkeypatch.delenv("GITLAB_PRIVATE_TOKEN", raising=False)
+    monkeypatch.delenv("GITLAB_URL", raising=False)
+    monkeypatch.delenv("CI_JOB_TOKEN", raising=False)
+    monkeypatch.delenv("CI_SERVER_URL", raising=False)
+
+
+@pytest.fixture(autouse=True)
+def default_files(monkeypatch: pytest.MonkeyPatch) -> None:
+    """Ensures user configuration files do not interfere with tests."""
+    monkeypatch.setattr(gitlab.config, "_DEFAULT_FILES", [])
+
+
+@pytest.fixture
+def valid_gitlab_ci_yml() -> str:
+    return """---
+:test_job:
+  :script: echo 1
+"""
+
+
+@pytest.fixture
+def invalid_gitlab_ci_yml() -> str:
+    return "invalid"
diff --git a/tests/functional/__init__.py b/tests/functional/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/functional/api/__init__.py b/tests/functional/api/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/functional/api/test_boards.py b/tests/functional/api/test_boards.py
new file mode 100644
index 000000000..1679a14e9
--- /dev/null
+++ b/tests/functional/api/test_boards.py
@@ -0,0 +1,16 @@
+def test_project_boards(project):
+    assert not project.boards.list()
+
+    board = project.boards.create({"name": "testboard"})
+    board = project.boards.get(board.id)
+
+    project.boards.delete(board.id)
+
+
+def test_group_boards(group):
+    assert not group.boards.list()
+
+    board = group.boards.create({"name": "testboard"})
+    board = group.boards.get(board.id)
+
+    group.boards.delete(board.id)
diff --git a/tests/functional/api/test_branches.py b/tests/functional/api/test_branches.py
new file mode 100644
index 000000000..0621705cf
--- /dev/null
+++ b/tests/functional/api/test_branches.py
@@ -0,0 +1,17 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/branches.html
+"""
+
+
+def test_branch_name_with_period(project):
+    # Make sure we can create and get a branch name containing a period '.'
+    branch_name = "my.branch.name"
+    branch = project.branches.create({"branch": branch_name, "ref": "main"})
+    assert branch.name == branch_name
+
+    # Ensure we can get the branch
+    fetched_branch = project.branches.get(branch_name)
+    assert branch.name == fetched_branch.name
+
+    branch.delete()
diff --git a/tests/functional/api/test_bulk_imports.py b/tests/functional/api/test_bulk_imports.py
new file mode 100644
index 000000000..4ccd55926
--- /dev/null
+++ b/tests/functional/api/test_bulk_imports.py
@@ -0,0 +1,63 @@
+import time
+
+import pytest
+
+import gitlab
+
+
+@pytest.fixture
+def bulk_import_enabled(gl: gitlab.Gitlab):
+    settings = gl.settings.get()
+    bulk_import_default = settings.bulk_import_enabled
+
+    settings.bulk_import_enabled = True
+    settings.save()
+
+    # todo: why so fussy with feature flag timing?
+    time.sleep(5)
+    get_settings = gl.settings.get()
+    assert get_settings.bulk_import_enabled is True
+
+    yield settings
+
+    settings.bulk_import_enabled = bulk_import_default
+    settings.save()
+
+
+# https://github.com/python-gitlab/python-gitlab/pull/2790#pullrequestreview-1873617123
+@pytest.mark.xfail(reason="Bulk Imports to be worked on in a follow up")
+def test_bulk_imports(gl, group, bulk_import_enabled):
+    destination = f"{group.full_path}-import"
+    configuration = {"url": gl.url, "access_token": gl.private_token}
+    migration_entity = {
+        "source_full_path": group.full_path,
+        "source_type": "group_entity",
+        "destination_slug": destination,
+        "destination_namespace": destination,
+    }
+    created_migration = gl.bulk_imports.create(
+        {"configuration": configuration, "entities": [migration_entity]}
+    )
+
+    assert created_migration.source_type == "gitlab"
+    assert created_migration.status == "created"
+
+    migration = gl.bulk_imports.get(created_migration.id)
+    assert migration == created_migration
+
+    migration.refresh()
+    assert migration == created_migration
+
+    migrations = gl.bulk_imports.list()
+    assert migration in migrations
+
+    all_entities = gl.bulk_import_entities.list()
+    entities = migration.entities.list()
+    assert isinstance(entities, list)
+    assert entities[0] in all_entities
+
+    entity = migration.entities.get(entities[0].id)
+    assert entity == entities[0]
+
+    entity.refresh()
+    assert entity.created_at == entities[0].created_at
diff --git a/tests/functional/api/test_current_user.py b/tests/functional/api/test_current_user.py
new file mode 100644
index 000000000..561dbe4b0
--- /dev/null
+++ b/tests/functional/api/test_current_user.py
@@ -0,0 +1,40 @@
+def test_current_user_email(gl):
+    gl.auth()
+    mail = gl.user.emails.create({"email": "current@user.com"})
+    assert mail in gl.user.emails.list()
+
+    mail.delete()
+
+
+def test_current_user_gpg_keys(gl, GPG_KEY):
+    gl.auth()
+    gkey = gl.user.gpgkeys.create({"key": GPG_KEY})
+    assert gkey in gl.user.gpgkeys.list()
+
+    # Seems broken on the gitlab side
+    gkey = gl.user.gpgkeys.get(gkey.id)
+
+    gkey.delete()
+
+
+def test_current_user_ssh_keys(gl, SSH_KEY):
+    gl.auth()
+    key = gl.user.keys.create({"title": "testkey", "key": SSH_KEY})
+    assert key in gl.user.keys.list()
+
+    key.delete()
+
+
+def test_current_user_status(gl):
+    gl.auth()
+    message = "Test"
+    emoji = "thumbsup"
+    status = gl.user.status.get()
+
+    status.message = message
+    status.emoji = emoji
+    status.save()
+
+    new_status = gl.user.status.get()
+    assert new_status.message == message
+    assert new_status.emoji == emoji
diff --git a/tests/functional/api/test_deploy_keys.py b/tests/functional/api/test_deploy_keys.py
new file mode 100644
index 000000000..127831781
--- /dev/null
+++ b/tests/functional/api/test_deploy_keys.py
@@ -0,0 +1,20 @@
+from gitlab import Gitlab
+from gitlab.v4.objects import Project
+
+
+def test_deploy_keys(gl: Gitlab, DEPLOY_KEY: str) -> None:
+    deploy_key = gl.deploykeys.create({"title": "foo@bar", "key": DEPLOY_KEY})
+    assert deploy_key in gl.deploykeys.list(get_all=False)
+
+
+def test_project_deploy_keys(gl: Gitlab, project: Project, DEPLOY_KEY: str) -> None:
+    deploy_key = project.keys.create({"title": "foo@bar", "key": DEPLOY_KEY})
+    assert deploy_key in project.keys.list()
+
+    project2 = gl.projects.create({"name": "deploy-key-project"})
+    project2.keys.enable(deploy_key.id)
+    assert deploy_key in project2.keys.list()
+
+    project2.keys.delete(deploy_key.id)
+
+    project2.delete()
diff --git a/tests/functional/api/test_deploy_tokens.py b/tests/functional/api/test_deploy_tokens.py
new file mode 100644
index 000000000..ffb2a1bcd
--- /dev/null
+++ b/tests/functional/api/test_deploy_tokens.py
@@ -0,0 +1,38 @@
+import datetime
+
+
+def test_project_deploy_tokens(gl, project):
+    today = datetime.date.today().isoformat()
+    deploy_token = project.deploytokens.create(
+        {
+            "name": "foo",
+            "username": "bar",
+            "expires_at": today,
+            "scopes": ["read_registry"],
+        }
+    )
+    assert deploy_token in project.deploytokens.list()
+    assert set(project.deploytokens.list()) <= set(gl.deploytokens.list())
+
+    deploy_token = project.deploytokens.get(deploy_token.id)
+    assert deploy_token.name == "foo"
+    assert deploy_token.expires_at == f"{today}T00:00:00.000Z"
+    assert deploy_token.scopes == ["read_registry"]
+    assert deploy_token.username == "bar"
+
+    deploy_token.delete()
+
+
+def test_group_deploy_tokens(gl, group):
+    deploy_token = group.deploytokens.create(
+        {"name": "foo", "scopes": ["read_registry"]}
+    )
+
+    assert deploy_token in group.deploytokens.list()
+    assert set(group.deploytokens.list()) <= set(gl.deploytokens.list())
+
+    deploy_token = group.deploytokens.get(deploy_token.id)
+    assert deploy_token.name == "foo"
+    assert deploy_token.scopes == ["read_registry"]
+
+    deploy_token.delete()
diff --git a/tests/functional/api/test_epics.py b/tests/functional/api/test_epics.py
new file mode 100644
index 000000000..a4f6765da
--- /dev/null
+++ b/tests/functional/api/test_epics.py
@@ -0,0 +1,32 @@
+import pytest
+
+pytestmark = pytest.mark.gitlab_premium
+
+
+def test_epics(group):
+    epic = group.epics.create({"title": "Test epic"})
+    epic.title = "Fixed title"
+    epic.labels = ["label1", "label2"]
+    epic.save()
+
+    epic = group.epics.get(epic.iid)
+    assert epic.title == "Fixed title"
+    assert epic.labels == ["label1", "label2"]
+    assert group.epics.list()
+
+
+@pytest.mark.xfail(reason="404 on issue.id")
+def test_epic_issues(epic, issue):
+    assert not epic.issues.list()
+
+    epic_issue = epic.issues.create({"issue_id": issue.id})
+    assert epic.issues.list()
+
+    epic_issue.delete()
+
+
+def test_epic_notes(epic):
+    assert not epic.notes.list()
+
+    epic.notes.create({"body": "Test note"})
+    assert epic.notes.list()
diff --git a/tests/functional/api/test_gitlab.py b/tests/functional/api/test_gitlab.py
new file mode 100644
index 000000000..50c6badd6
--- /dev/null
+++ b/tests/functional/api/test_gitlab.py
@@ -0,0 +1,282 @@
+import pytest
+import requests
+
+import gitlab
+
+
+@pytest.fixture(
+    scope="session",
+    params=[{"get_all": True}, {"all": True}],
+    ids=["get_all=True", "all=True"],
+)
+def get_all_kwargs(request):
+    """A tiny parametrized fixture to inject both `get_all=True` and
+    `all=True` to ensure they behave the same way for pagination."""
+    return request.param
+
+
+def test_auth_from_config(gl, gitlab_config, temp_dir):
+    """Test token authentication from config file"""
+    test_gitlab = gitlab.Gitlab.from_config(config_files=[gitlab_config])
+    test_gitlab.auth()
+    assert isinstance(test_gitlab.user, gitlab.v4.objects.CurrentUser)
+
+
+def test_no_custom_session(gl, temp_dir):
+    """Test no custom session"""
+    custom_session = requests.Session()
+    test_gitlab = gitlab.Gitlab.from_config(
+        config_files=[temp_dir / "python-gitlab.cfg"]
+    )
+    assert test_gitlab.session != custom_session
+
+
+def test_custom_session(gl, temp_dir):
+    """Test custom session"""
+    custom_session = requests.Session()
+    test_gitlab = gitlab.Gitlab.from_config(
+        config_files=[temp_dir / "python-gitlab.cfg"], session=custom_session
+    )
+    assert test_gitlab.session == custom_session
+
+
+def test_broadcast_messages(gl, get_all_kwargs):
+    msg = gl.broadcastmessages.create({"message": "this is the message"})
+    msg.color = "#444444"
+    msg.save()
+    msg_id = msg.id
+
+    msg = gl.broadcastmessages.list(**get_all_kwargs)[0]
+    assert msg.color == "#444444"
+
+    msg = gl.broadcastmessages.get(msg_id)
+    assert msg.color == "#444444"
+
+    msg.delete()
+
+
+def test_markdown(gl):
+    html = gl.markdown("foo")
+    assert "foo" in html
+
+
+def test_markdown_in_project(gl, project):
+    html = gl.markdown("foo", project=project.path_with_namespace)
+    assert "foo" in html
+
+
+def test_sidekiq_queue_metrics(gl):
+    out = gl.sidekiq.queue_metrics()
+    assert isinstance(out, dict)
+    assert "default" in out["queues"]
+
+
+def test_sidekiq_process_metrics(gl):
+    out = gl.sidekiq.process_metrics()
+    assert isinstance(out, dict)
+    assert "hostname" in out["processes"][0]
+
+
+def test_sidekiq_job_stats(gl):
+    out = gl.sidekiq.job_stats()
+    assert isinstance(out, dict)
+    assert "processed" in out["jobs"]
+
+
+def test_sidekiq_compound_metrics(gl):
+    out = gl.sidekiq.compound_metrics()
+    assert isinstance(out, dict)
+    assert "jobs" in out
+    assert "processes" in out
+    assert "queues" in out
+
+
+@pytest.mark.gitlab_premium
+def test_geo_nodes(gl):
+    # Very basic geo nodes tests because we only have 1 node.
+    nodes = gl.geonodes.list()
+    assert isinstance(nodes, list)
+
+    status = gl.geonodes.status()
+    assert isinstance(status, list)
+
+
+@pytest.mark.gitlab_premium
+def test_gitlab_license(gl):
+    license = gl.get_license()
+    assert "user_limit" in license
+
+    with pytest.raises(gitlab.GitlabLicenseError, match="The license key is invalid."):
+        gl.set_license("dummy key")
+
+
+def test_gitlab_settings(gl):
+    settings = gl.settings.get()
+    settings.default_projects_limit = 42
+    settings.save()
+    settings = gl.settings.get()
+    assert settings.default_projects_limit == 42
+
+
+def test_template_dockerfile(gl):
+    assert gl.dockerfiles.list()
+
+    dockerfile = gl.dockerfiles.get("Node")
+    assert dockerfile.content is not None
+
+
+def test_template_gitignore(gl, get_all_kwargs):
+    assert gl.gitignores.list(**get_all_kwargs)
+    gitignore = gl.gitignores.get("Node")
+    assert gitignore.content is not None
+
+
+def test_template_gitlabciyml(gl, get_all_kwargs):
+    assert gl.gitlabciymls.list(**get_all_kwargs)
+    gitlabciyml = gl.gitlabciymls.get("Nodejs")
+    assert gitlabciyml.content is not None
+
+
+def test_template_license(gl):
+    assert gl.licenses.list(get_all=False)
+    license = gl.licenses.get(
+        "bsd-2-clause", project="mytestproject", fullname="mytestfullname"
+    )
+    assert "mytestfullname" in license.content
+
+
+def test_hooks(gl):
+    hook = gl.hooks.create({"url": "http://whatever.com"})
+    assert hook in gl.hooks.list()
+
+    hook.delete()
+
+
+def test_namespaces(gl, get_all_kwargs):
+    gl.auth()
+    current_user = gl.user.username
+
+    namespaces = gl.namespaces.list(**get_all_kwargs)
+    assert namespaces
+
+    namespaces = gl.namespaces.list(search=current_user, **get_all_kwargs)
+    assert namespaces[0].kind == "user"
+
+    namespace = gl.namespaces.get(current_user)
+    assert namespace.kind == "user"
+
+    namespace = gl.namespaces.exists(current_user)
+    assert namespace.exists
+
+
+def test_notification_settings(gl):
+    settings = gl.notificationsettings.get()
+    settings.level = gitlab.const.NotificationLevel.WATCH
+    settings.save()
+
+    settings = gl.notificationsettings.get()
+    assert settings.level == gitlab.const.NotificationLevel.WATCH
+
+
+def test_search(gl):
+    result = gl.search(scope=gitlab.const.SearchScope.USERS, search="Administrator")
+    assert result[0]["id"] == 1
+
+
+def test_user_activities(gl):
+    activities = gl.user_activities.list(query_parameters={"from": "2019-01-01"})
+    assert isinstance(activities, list)
+
+
+def test_events(gl):
+    events = gl.events.list()
+    assert isinstance(events, list)
+
+
+@pytest.mark.skip
+def test_features(gl):
+    feat = gl.features.set("foo", 30)
+    assert feat.name == "foo"
+    assert feat in gl.features.list()
+
+    feat.delete()
+
+
+def test_pagination(gl, project):
+    project2 = gl.projects.create({"name": "project-page-2"})
+
+    list1 = gl.projects.list(per_page=1, page=1)
+    list2 = gl.projects.list(per_page=1, page=2)
+    assert len(list1) == 1
+    assert len(list2) == 1
+    assert list1[0].id != list2[0].id
+
+    project2.delete()
+
+
+def test_rate_limits(gl):
+    settings = gl.settings.get()
+    settings.throttle_authenticated_api_enabled = True
+    settings.throttle_authenticated_api_requests_per_period = 1
+    settings.throttle_authenticated_api_period_in_seconds = 3
+    settings.save()
+
+    projects = []
+    for i in range(0, 20):
+        projects.append(gl.projects.create({"name": f"{str(i)}ok"}))
+
+    with pytest.raises(gitlab.GitlabCreateError) as e:
+        for i in range(20, 40):
+            projects.append(
+                gl.projects.create(
+                    {"name": f"{str(i)}shouldfail"}, obey_rate_limit=False
+                )
+            )
+
+    assert "Retry later" in str(e.value)
+
+    settings.throttle_authenticated_api_enabled = False
+    settings.save()
+    [project.delete() for project in projects]
+
+
+def test_list_default_warning(gl):
+    """When there are more than 20 items and use default `list()` then warning is
+    generated"""
+    with pytest.warns(UserWarning, match="python-gitlab.readthedocs.io") as record:
+        gl.gitlabciymls.list()
+
+    assert len(record) == 1
+    warning = record[0]
+    assert __file__ == warning.filename
+    assert __file__ in str(warning.message)
+
+
+def test_list_page_nowarning(gl, recwarn):
+    """Using `page=X` will disable the warning"""
+    gl.gitlabciymls.list(page=1)
+    assert not recwarn
+
+
+def test_list_all_false_nowarning(gl, recwarn):
+    """Using `all=False` will disable the warning"""
+    gl.gitlabciymls.list(all=False)
+    assert not recwarn
+
+
+def test_list_all_true_nowarning(gl, get_all_kwargs, recwarn):
+    """Using `get_all=True` will disable the warning"""
+    items = gl.gitlabciymls.list(**get_all_kwargs)
+    for warn in recwarn:
+        if issubclass(warn.category, UserWarning):
+            # Our warning has a link to the docs in it, make sure we don't have
+            # that.
+            assert "python-gitlab.readthedocs.io" not in str(warn.message)
+    assert len(items) > 20
+
+
+def test_list_iterator_true_nowarning(gl, recwarn):
+    """Using `iterator=True` will disable the warning"""
+    items = gl.gitlabciymls.list(iterator=True)
+    assert not recwarn
+    assert len(list(items)) > 20
diff --git a/tests/functional/api/test_graphql.py b/tests/functional/api/test_graphql.py
new file mode 100644
index 000000000..600c05ee0
--- /dev/null
+++ b/tests/functional/api/test_graphql.py
@@ -0,0 +1,28 @@
+import pytest
+
+import gitlab
+
+
+@pytest.fixture
+def gl_gql(gitlab_url: str, gitlab_token: str) -> gitlab.GraphQL:
+    return gitlab.GraphQL(gitlab_url, token=gitlab_token)
+
+
+@pytest.fixture
+def gl_async_gql(gitlab_url: str, gitlab_token: str) -> gitlab.AsyncGraphQL:
+    return gitlab.AsyncGraphQL(gitlab_url, token=gitlab_token)
+
+
+def test_query_returns_valid_response(gl_gql: gitlab.GraphQL):
+    query = "query {currentUser {active}}"
+
+    response = gl_gql.execute(query)
+    assert response["currentUser"]["active"] is True
+
+
+@pytest.mark.anyio
+async def test_async_query_returns_valid_response(gl_async_gql: gitlab.AsyncGraphQL):
+    query = "query {currentUser {active}}"
+
+    response = await gl_async_gql.execute(query)
+    assert response["currentUser"]["active"] is True
diff --git a/tests/functional/api/test_groups.py b/tests/functional/api/test_groups.py
new file mode 100644
index 000000000..301fea6a2
--- /dev/null
+++ b/tests/functional/api/test_groups.py
@@ -0,0 +1,372 @@
+import pytest
+
+import gitlab
+
+
+def test_groups(gl):
+    # TODO: This one still needs lots of work
+    user = gl.users.create(
+        {
+            "email": "user@test.com",
+            "username": "user",
+            "name": "user",
+            "password": "E4596f8be406Bc3a14a4ccdb1df80587#!1",
+        }
+    )
+    user2 = gl.users.create(
+        {
+            "email": "user2@test.com",
+            "username": "user2",
+            "name": "user2",
+            "password": "E4596f8be406Bc3a14a4ccdb1df80587#!#2",
+        }
+    )
+    group1 = gl.groups.create(
+        {"name": "gitlab-test-group1", "path": "gitlab-test-group1"}
+    )
+    group2 = gl.groups.create(
+        {"name": "gitlab-test-group2", "path": "gitlab-test-group2"}
+    )
+
+    p_id = gl.groups.list(search="gitlab-test-group2")[0].id
+    group3 = gl.groups.create(
+        {"name": "gitlab-test-group3", "path": "gitlab-test-group3", "parent_id": p_id}
+    )
+    group4 = gl.groups.create(
+        {"name": "gitlab-test-group4", "path": "gitlab-test-group4"}
+    )
+
+    assert {group1, group2, group3, group4} <= set(gl.groups.list())
+    assert gl.groups.list(search="gitlab-test-group1")[0].id == group1.id
+    assert group3.parent_id == p_id
+    assert group2.subgroups.list()[0].id == group3.id
+    assert group2.descendant_groups.list()[0].id == group3.id
+
+    filtered_groups = gl.groups.list(skip_groups=[group3.id, group4.id])
+    assert group3 not in filtered_groups
+    assert group4 not in filtered_groups
+
+    filtered_groups = gl.groups.list(skip_groups=[group3.id])
+    assert group3 not in filtered_groups
+    assert group4 in filtered_groups
+
+    group1.members.create(
+        {"access_level": gitlab.const.AccessLevel.OWNER, "user_id": user.id}
+    )
+    group1.members.create(
+        {"access_level": gitlab.const.AccessLevel.GUEST, "user_id": user2.id}
+    )
+    group2.members.create(
+        {"access_level": gitlab.const.AccessLevel.OWNER, "user_id": user2.id}
+    )
+
+    group4.share(group1.id, gitlab.const.AccessLevel.DEVELOPER)
+    group4.share(group2.id, gitlab.const.AccessLevel.MAINTAINER)
+    # Reload group4 to have updated shared_with_groups
+    group4 = gl.groups.get(group4.id)
+    assert len(group4.shared_with_groups) == 2
+    group4.unshare(group1.id)
+    # Reload group4 to have updated shared_with_groups
+    group4 = gl.groups.get(group4.id)
+    assert len(group4.shared_with_groups) == 1
+
+    # User memberships (admin only)
+    memberships1 = user.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.AccessLevel.OWNER
+
+    project_memberships = user.memberships.list(type="Project")
+    assert len(project_memberships) == 0
+
+    group_memberships = user.memberships.list(type="Namespace")
+    assert len(group_memberships) == 1
+
+    with pytest.raises(gitlab.GitlabListError) as e:
+        membership = user.memberships.list(type="Invalid")
+    assert "type does not have a valid value" in str(e.value)
+
+    with pytest.raises(gitlab.GitlabListError) as e:
+        user.memberships.list(sudo=user.name)
+    assert "403 Forbidden" in str(e.value)
+
+    # Administrator belongs to the groups
+    assert len(group1.members.list()) == 3
+    assert len(group2.members.list()) == 2
+
+    # Test `user_ids` array
+    result = group1.members.list(user_ids=[user.id, 99999])
+    assert len(result) == 1
+    assert result[0].id == user.id
+
+    group1.members.delete(user.id)
+
+    assert group1.members_all.list()
+
+    member = group1.members.get(user2.id)
+    member.access_level = gitlab.const.AccessLevel.OWNER
+    member.save()
+    member = group1.members.get(user2.id)
+    assert member.access_level == gitlab.const.AccessLevel.OWNER
+
+    gl.auth()
+    group2.members.delete(gl.user.id)
+
+
+def test_group_labels(group):
+    group.labels.create({"name": "foo", "description": "bar", "color": "#112233"})
+    label = group.labels.get("foo")
+    assert label.description == "bar"
+
+    label.description = "baz"
+    label.save()
+    label = group.labels.get("foo")
+    assert label.description == "baz"
+    assert label in group.labels.list()
+
+    label.new_name = "Label:that requires:encoding"
+    label.save()
+    assert label.name == "Label:that requires:encoding"
+    label = group.labels.get("Label:that requires:encoding")
+    assert label.name == "Label:that requires:encoding"
+
+    label.delete()
+
+
+def test_group_avatar_upload(gl, group, fixture_dir):
+    """Test uploading an avatar to a group."""
+    # Upload avatar
+    with open(fixture_dir / "avatar.png", "rb") as avatar_file:
+        group.avatar = avatar_file
+        group.save()
+
+    # Verify the avatar was set
+    updated_group = gl.groups.get(group.id)
+    assert updated_group.avatar_url is not None
+
+
+def test_group_avatar_remove(gl, group, fixture_dir):
+    """Test removing an avatar from a group."""
+    # First set an avatar
+    with open(fixture_dir / "avatar.png", "rb") as avatar_file:
+        group.avatar = avatar_file
+        group.save()
+
+    # Now remove the avatar
+    group.avatar = ""
+    group.save()
+
+    # Verify the avatar was removed
+    updated_group = gl.groups.get(group.id)
+    assert updated_group.avatar_url is None
+
+
+@pytest.mark.gitlab_premium
+@pytest.mark.xfail(reason="/ldap/groups endpoint not documented")
+def test_ldap_groups(gl):
+    assert isinstance(gl.ldapgroups.list(), list)
+
+
+@pytest.mark.gitlab_premium
+def test_group_ldap_links(group):
+    ldap_cn = "common-name"
+    ldap_provider = "ldap-provider"
+
+    ldap_cn_link = group.ldap_group_links.create(
+        {"provider": ldap_provider, "group_access": 30, "cn": ldap_cn}
+    )
+    ldap_filter_link = group.ldap_group_links.create(
+        {"provider": ldap_provider, "group_access": 30, "filter": "(cn=Common Name)"}
+    )
+
+    ldap_links = group.ldap_group_links.list()
+
+    assert ldap_cn_link.cn == ldap_links[0].cn
+    assert ldap_filter_link.filter == ldap_links[1].filter
+
+    with pytest.raises(gitlab.GitlabCreateError):
+        # todo - can we configure dummy LDAP in the container?
+        group.ldap_sync()
+
+    ldap_filter_link.delete()
+    group.ldap_group_links.delete(provider=ldap_provider, cn=ldap_cn)
+
+    with pytest.raises(gitlab.GitlabListError, match="No linked LDAP groups found"):
+        group.ldap_group_links.list()
+
+
+def test_group_notification_settings(group):
+    settings = group.notificationsettings.get()
+    settings.level = "disabled"
+    settings.save()
+
+    settings = group.notificationsettings.get()
+    assert settings.level == "disabled"
+
+
+def test_group_badges(group):
+    badge_image = "http://example.com"
+    badge_link = "http://example/img.svg"
+    badge = group.badges.create({"link_url": badge_link, "image_url": badge_image})
+    assert badge in group.badges.list()
+
+    badge.image_url = "http://another.example.com"
+    badge.save()
+
+    badge = group.badges.get(badge.id)
+    assert badge.image_url == "http://another.example.com"
+
+    badge.delete()
+
+
+def test_group_milestones(group):
+    milestone = group.milestones.create({"title": "groupmilestone1"})
+    assert milestone in group.milestones.list()
+
+    milestone.due_date = "2020-01-01T00:00:00Z"
+    milestone.save()
+    milestone.state_event = "close"
+    milestone.save()
+
+    milestone = group.milestones.get(milestone.id)
+    assert milestone.state == "closed"
+    assert not milestone.issues()
+    assert not milestone.merge_requests()
+
+
+def test_group_custom_attributes(gl, group):
+    attrs = group.customattributes.list()
+    assert not attrs
+
+    attr = group.customattributes.set("key", "value1")
+    assert group in gl.groups.list(custom_attributes={"key": "value1"})
+    assert attr.key == "key"
+    assert attr.value == "value1"
+    assert attr in group.customattributes.list()
+
+    attr = group.customattributes.set("key", "value2")
+    attr = group.customattributes.get("key")
+    assert attr.value == "value2"
+    assert attr in group.customattributes.list()
+
+    attr.delete()
+
+
+def test_group_subgroups_projects(gl, user):
+    # TODO: fixture factories
+    group1 = gl.groups.list(search="group1")[0]
+    group2 = gl.groups.list(search="group2")[0]
+
+    group3 = gl.groups.create(
+        {"name": "subgroup1", "path": "subgroup1", "parent_id": group1.id}
+    )
+    group4 = gl.groups.create(
+        {"name": "subgroup2", "path": "subgroup2", "parent_id": group2.id}
+    )
+
+    gr1_project = gl.projects.create({"name": "gr1_project", "namespace_id": group1.id})
+    gr2_project = gl.projects.create({"name": "gr2_project", "namespace_id": group3.id})
+
+    assert group3.parent_id == group1.id
+    assert group4.parent_id == group2.id
+    assert gr1_project.namespace["id"] == group1.id
+    assert gr2_project.namespace["parent_id"] == group1.id
+
+    gr1_project.delete()
+    gr2_project.delete()
+    group3.delete()
+    group4.delete()
+
+
+@pytest.mark.gitlab_premium
+def test_group_wiki(group):
+    content = "Group Wiki page content"
+    wiki = group.wikis.create({"title": "groupwikipage", "content": content})
+    assert wiki in group.wikis.list()
+
+    wiki = group.wikis.get(wiki.slug)
+    assert wiki.content == content
+
+    wiki.content = "new content"
+    wiki.save()
+
+    wiki.delete()
+
+
+@pytest.mark.gitlab_premium
+def test_group_hooks(group):
+    hook = group.hooks.create({"url": "http://hook.url"})
+    assert hook in group.hooks.list()
+
+    hook.note_events = True
+    hook.save()
+
+    hook = group.hooks.get(hook.id)
+    assert hook.note_events is True
+
+    hook.delete()
+
+
+def test_group_protected_branches(group, gitlab_version):
+    # Updating a protected branch at the group level is possible from Gitlab 15.9
+    # https://docs.gitlab.com/api/group_protected_branches/
+    can_update_prot_branch = gitlab_version.major > 15 or (
+        gitlab_version.major == 15 and gitlab_version.minor >= 9
+    )
+
+    p_b = group.protectedbranches.create(
+        {"name": "*-stable", "allow_force_push": False}
+    )
+    assert p_b.name == "*-stable"
+    assert not p_b.allow_force_push
+    assert p_b in group.protectedbranches.list()
+
+    if can_update_prot_branch:
+        p_b.allow_force_push = True
+        p_b.save()
+
+    p_b = group.protectedbranches.get("*-stable")
+    if can_update_prot_branch:
+        assert p_b.allow_force_push
+
+        p_b.delete()
+
+
+def test_group_transfer(gl, group):
+    transfer_group = gl.groups.create(
+        {"name": "transfer-test-group", "path": "transfer-test-group"}
+    )
+    transfer_group = gl.groups.get(transfer_group.id)
+    assert transfer_group.parent_id != group.id
+
+    transfer_group.transfer(group.id)
+
+    transferred_group = gl.groups.get(transfer_group.id)
+    assert transferred_group.parent_id == group.id
+
+    transfer_group.transfer()
+
+    transferred_group = gl.groups.get(transfer_group.id)
+    assert transferred_group.path == transferred_group.full_path
+
+
+@pytest.mark.gitlab_premium
+@pytest.mark.xfail(reason="need to setup an identity provider or it's mock")
+def test_group_saml_group_links(group):
+    group.saml_group_links.create(
+        {"saml_group_name": "saml-group-1", "access_level": 10}
+    )
+
+
+@pytest.mark.gitlab_premium
+def test_group_service_account(group):
+    service_account = group.service_accounts.create(
+        {"name": "gitlab-service-account", "username": "gitlab-service-account"}
+    )
+    assert service_account.name == "gitlab-service-account"
+    assert service_account.username == "gitlab-service-account"
diff --git a/tests/functional/api/test_import_export.py b/tests/functional/api/test_import_export.py
new file mode 100644
index 000000000..f7444c92c
--- /dev/null
+++ b/tests/functional/api/test_import_export.py
@@ -0,0 +1,109 @@
+import time
+
+import pytest
+
+import gitlab
+
+
+# https://github.com/python-gitlab/python-gitlab/pull/2790#pullrequestreview-1873617123
+def test_group_import_export(gl, group, temp_dir):
+    export = group.exports.create()
+    assert export.message == "202 Accepted"
+
+    # We cannot check for export_status with group export API
+    time.sleep(10)
+
+    import_archive = temp_dir / "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
+
+
+# https://github.com/python-gitlab/python-gitlab/pull/2790#pullrequestreview-1873617123
+@pytest.mark.xfail(reason="test_project_import_export to be worked on in a follow up")
+def test_project_import_export(gl, project, temp_dir):
+    export = project.exports.create()
+    assert export.message == "202 Accepted"
+
+    export = project.exports.get()
+    assert isinstance(export, gitlab.v4.objects.ProjectExport)
+
+    count = 0
+    while export.export_status != "finished":
+        time.sleep(1)
+        export.refresh()
+        count += 1
+        if count == 15:
+            raise Exception("Project export taking too much time")
+
+    with open(temp_dir / "gitlab-export.tgz", "wb") as f:
+        export.download(streamed=True, action=f.write)
+
+    output = gl.projects.import_project(
+        open(temp_dir / "gitlab-export.tgz", "rb"),
+        "imported_project",
+        name="Imported Project",
+    )
+    project_import = gl.projects.get(output["id"], lazy=True).imports.get()
+
+    assert project_import.path == "imported_project"
+    assert project_import.name == "Imported Project"
+
+    count = 0
+    while project_import.import_status != "finished":
+        time.sleep(1)
+        project_import.refresh()
+        count += 1
+        if count == 15:
+            raise Exception("Project import taking too much time")
+
+
+# https://github.com/python-gitlab/python-gitlab/pull/2790#pullrequestreview-1873617123
+@pytest.mark.xfail(reason="test_project_remote_import to be worked on in a follow up")
+def test_project_remote_import(gl):
+    with pytest.raises(gitlab.exceptions.GitlabImportError) as err_info:
+        gl.projects.remote_import(
+            "ftp://whatever.com/url", "remote-project", "remote-project", "root"
+        )
+    assert err_info.value.response_code == 400
+    assert (
+        "File url is blocked: Only allowed schemes are https"
+        in err_info.value.error_message
+    )
+
+
+# https://github.com/python-gitlab/python-gitlab/pull/2790#pullrequestreview-1873617123
+@pytest.mark.xfail(
+    reason="test_project_remote_import_s3 to be worked on in a follow up"
+)
+def test_project_remote_import_s3(gl):
+    gl.features.set("import_project_from_remote_file_s3", True)
+    with pytest.raises(gitlab.exceptions.GitlabImportError) as err_info:
+        gl.projects.remote_import_s3(
+            "remote-project",
+            "aws-region",
+            "aws-bucket-name",
+            "aws-file-key",
+            "aws-access-key-id",
+            "secret-access-key",
+            "remote-project",
+            "root",
+        )
+    assert err_info.value.response_code == 400
+    assert (
+        "Failed to open 'aws-file-key' in 'aws-bucket-name'"
+        in err_info.value.error_message
+    )
diff --git a/tests/functional/api/test_issues.py b/tests/functional/api/test_issues.py
new file mode 100644
index 000000000..cd662f816
--- /dev/null
+++ b/tests/functional/api/test_issues.py
@@ -0,0 +1,113 @@
+import gitlab
+
+
+def test_create_issue(project):
+    issue = project.issues.create({"title": "my issue 1"})
+    issue2 = project.issues.create({"title": "my issue 2"})
+
+    issues = project.issues.list()
+    issue_iids = [issue.iid for issue in issues]
+    assert {issue, issue2} <= set(issues)
+
+    # Test 'iids' as a list
+    filtered_issues = project.issues.list(iids=issue_iids)
+    assert {issue, issue2} == set(filtered_issues)
+
+    issue2.state_event = "close"
+    issue2.save()
+    assert issue in project.issues.list(state="opened")
+    assert issue2 in project.issues.list(state="closed")
+
+    participants = issue.participants()
+    assert participants
+    assert isinstance(participants, list)
+    assert type(issue.closed_by()) == list
+    assert type(issue.related_merge_requests()) == list
+
+
+def test_issue_notes(issue):
+    note = issue.notes.create({"body": "This is an issue note"})
+    assert note in issue.notes.list()
+
+    emoji = note.awardemojis.create({"name": "tractor"})
+    assert emoji in note.awardemojis.list()
+
+    emoji.delete()
+    note.delete()
+
+
+def test_issue_labels(project, issue):
+    project.labels.create({"name": "label2", "color": "#aabbcc"})
+    issue.labels = ["label2"]
+    issue.save()
+
+    assert issue in project.issues.list(labels=["label2"])
+    assert issue in project.issues.list(labels="label2")
+    assert issue in project.issues.list(labels="Any")
+    assert issue not in project.issues.list(labels="None")
+
+
+def test_issue_links(project, issue):
+    linked_issue = project.issues.create({"title": "Linked issue"})
+    source_issue, target_issue = issue.links.create(
+        {"target_project_id": project.id, "target_issue_iid": linked_issue.iid}
+    )
+    assert source_issue == issue
+    assert target_issue == linked_issue
+
+    links = issue.links.list()
+    assert links
+
+    link_id = links[0].issue_link_id
+
+    issue.links.delete(link_id)
+
+
+def test_issue_label_events(issue):
+    events = issue.resourcelabelevents.list()
+    assert isinstance(events, list)
+
+    event = issue.resourcelabelevents.get(events[0].id)
+    assert isinstance(event, gitlab.v4.objects.ProjectIssueResourceLabelEvent)
+
+
+def test_issue_weight_events(issue):
+    issue.weight = 13
+    issue.save()
+
+    events = issue.resource_weight_events.list()
+    assert isinstance(events, list)
+
+    event = issue.resource_weight_events.get(events[0].id)
+    assert isinstance(event, gitlab.v4.objects.ProjectIssueResourceWeightEvent)
+
+
+def test_issue_milestones(project, milestone):
+    data = {"title": "my issue 1", "milestone_id": milestone.id}
+    issue = project.issues.create(data)
+    assert milestone.issues().next().title == "my issue 1"
+
+    milestone_events = issue.resourcemilestoneevents.list()
+    assert isinstance(milestone_events, list)
+
+    milestone_event = issue.resourcemilestoneevents.get(milestone_events[0].id)
+    assert isinstance(
+        milestone_event, gitlab.v4.objects.ProjectIssueResourceMilestoneEvent
+    )
+
+    assert issue in project.issues.list(milestone=milestone.title)
+
+
+def test_issue_discussions(issue):
+    discussion = issue.discussions.create({"body": "Discussion body"})
+    assert discussion in issue.discussions.list()
+
+    d_note = discussion.notes.create({"body": "first note"})
+    d_note_from_get = discussion.notes.get(d_note.id)
+    d_note_from_get.body = "updated body"
+    d_note_from_get.save()
+
+    discussion = issue.discussions.get(discussion.id)
+    assert discussion.attributes["notes"][-1]["body"] == "updated body"
+
+    d_note_from_get.delete()
diff --git a/tests/functional/api/test_keys.py b/tests/functional/api/test_keys.py
new file mode 100644
index 000000000..359649bef
--- /dev/null
+++ b/tests/functional/api/test_keys.py
@@ -0,0 +1,43 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ce/api/keys.html
+"""
+
+import base64
+import hashlib
+
+
+def key_fingerprint(key: str) -> str:
+    key_part = key.split()[1]
+    decoded = base64.b64decode(key_part.encode("ascii"))
+    digest = hashlib.sha256(decoded).digest()
+    return f"SHA256:{base64.b64encode(digest).rstrip(b'=').decode('utf-8')}"
+
+
+def test_keys_ssh(gl, user, SSH_KEY):
+    key = user.keys.create({"title": "foo@bar", "key": SSH_KEY})
+
+    # Get key by ID (admin only).
+    key_by_id = gl.keys.get(key.id)
+    assert key_by_id.title == key.title
+    assert key_by_id.key == key.key
+
+    fingerprint = key_fingerprint(SSH_KEY)
+    # Get key by fingerprint (admin only).
+    key_by_fingerprint = gl.keys.get(fingerprint=fingerprint)
+    assert key_by_fingerprint.title == key.title
+    assert key_by_fingerprint.key == key.key
+
+    key.delete()
+
+
+def test_keys_deploy(gl, project, DEPLOY_KEY):
+    key = project.keys.create({"title": "foo@bar", "key": DEPLOY_KEY})
+
+    fingerprint = key_fingerprint(DEPLOY_KEY)
+    key_by_fingerprint = gl.keys.get(fingerprint=fingerprint)
+    assert key_by_fingerprint.title == key.title
+    assert key_by_fingerprint.key == key.key
+    assert len(key_by_fingerprint.deploy_keys_projects) == 1
+
+    key.delete()
diff --git a/tests/functional/api/test_lazy_objects.py b/tests/functional/api/test_lazy_objects.py
new file mode 100644
index 000000000..607a63648
--- /dev/null
+++ b/tests/functional/api/test_lazy_objects.py
@@ -0,0 +1,41 @@
+import time
+
+import pytest
+
+import gitlab
+
+
+@pytest.fixture
+def lazy_project(gl, project):
+    assert "/" in project.path_with_namespace
+    return gl.projects.get(project.path_with_namespace, lazy=True)
+
+
+def test_lazy_id(project, lazy_project):
+    assert isinstance(lazy_project.id, str)
+    assert isinstance(lazy_project.id, gitlab.utils.EncodedId)
+    assert lazy_project.id == gitlab.utils.EncodedId(project.path_with_namespace)
+
+
+def test_refresh_after_lazy_get_with_path(project, lazy_project):
+    lazy_project.refresh()
+    assert lazy_project.id == project.id
+
+
+def test_save_after_lazy_get_with_path(project, lazy_project):
+    lazy_project.description = "A new description"
+    lazy_project.save()
+    assert lazy_project.id == project.id
+    assert lazy_project.description == "A new description"
+
+
+def test_delete_after_lazy_get_with_path(gl, group):
+    project = gl.projects.create({"name": "lazy_project", "namespace_id": group.id})
+    # Pause to let GL catch up (happens on hosted too, sometimes takes a while for server to be ready to merge)
+    time.sleep(5)
+    lazy_project = gl.projects.get(project.path_with_namespace, lazy=True)
+    lazy_project.delete()
+
+
+def test_list_children_after_lazy_get_with_path(gl, lazy_project):
+    lazy_project.mergerequests.list()
diff --git a/tests/functional/api/test_member_roles.py b/tests/functional/api/test_member_roles.py
new file mode 100644
index 000000000..24cee7c69
--- /dev/null
+++ b/tests/functional/api/test_member_roles.py
@@ -0,0 +1,18 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/member_roles.html
+"""
+
+
+def test_instance_member_role(gl):
+    member_role = gl.member_roles.create(
+        {
+            "name": "Custom webhook manager role",
+            "base_access_level": 20,
+            "description": "Custom reporter that can manage webhooks",
+            "admin_web_hook": True,
+        }
+    )
+    assert member_role.id > 0
+    assert member_role in gl.member_roles.list()
+    gl.member_roles.delete(member_role.id)
diff --git a/tests/functional/api/test_merge_requests.py b/tests/functional/api/test_merge_requests.py
new file mode 100644
index 000000000..8357a817d
--- /dev/null
+++ b/tests/functional/api/test_merge_requests.py
@@ -0,0 +1,302 @@
+import datetime
+import time
+
+import pytest
+
+import gitlab
+import gitlab.v4.objects
+
+
+def test_merge_requests(project):
+    project.files.create(
+        {
+            "file_path": "README.rst",
+            "branch": "main",
+            "content": "Initial content",
+            "commit_message": "Initial commit",
+        }
+    )
+
+    source_branch = "branch-merge-request-api"
+    project.branches.create({"branch": source_branch, "ref": "main"})
+
+    project.files.create(
+        {
+            "file_path": "README2.rst",
+            "branch": source_branch,
+            "content": "Initial content",
+            "commit_message": "New commit in new branch",
+        }
+    )
+    project.mergerequests.create(
+        {"source_branch": source_branch, "target_branch": "main", "title": "MR readme2"}
+    )
+
+
+def test_merge_requests_get(project, merge_request):
+    mr = project.mergerequests.get(merge_request.iid)
+    assert mr.iid == merge_request.iid
+
+    mr = project.mergerequests.get(str(merge_request.iid))
+    assert mr.iid == merge_request.iid
+
+
+@pytest.mark.gitlab_premium
+def test_merge_requests_list_approver_ids(project):
+    # show https://github.com/python-gitlab/python-gitlab/issues/1698 is now
+    # fixed
+    project.mergerequests.list(
+        all=True, state="opened", author_id=423, approver_ids=[423]
+    )
+
+
+def test_merge_requests_get_lazy(project, merge_request):
+    mr = project.mergerequests.get(merge_request.iid, lazy=True)
+    assert mr.iid == merge_request.iid
+
+
+def test_merge_request_discussion(project):
+    mr = project.mergerequests.list()[0]
+
+    discussion = mr.discussions.create({"body": "Discussion body"})
+    assert discussion in mr.discussions.list()
+
+    note = discussion.notes.create({"body": "first note"})
+    note_from_get = discussion.notes.get(note.id)
+    note_from_get.body = "updated body"
+    note_from_get.save()
+
+    discussion = mr.discussions.get(discussion.id)
+    assert discussion.attributes["notes"][-1]["body"] == "updated body"
+
+    note_from_get.delete()
+
+
+def test_merge_request_labels(project):
+    mr = project.mergerequests.list()[0]
+    mr.labels = ["label2"]
+    mr.save()
+
+    events = mr.resourcelabelevents.list()
+    assert events
+
+    event = mr.resourcelabelevents.get(events[0].id)
+    assert event
+
+
+def test_merge_request_milestone_events(project, milestone):
+    mr = project.mergerequests.list()[0]
+    mr.milestone_id = milestone.id
+    mr.save()
+
+    milestones = mr.resourcemilestoneevents.list()
+    assert milestones
+
+    milestone = mr.resourcemilestoneevents.get(milestones[0].id)
+    assert milestone
+
+
+def test_merge_request_basic(project):
+    mr = project.mergerequests.list()[0]
+    # basic testing: only make sure that the methods exist
+    mr.commits()
+    mr.changes()
+    participants = mr.participants()
+    assert participants
+    assert isinstance(participants, list)
+
+
+def test_merge_request_rebase(project):
+    mr = project.mergerequests.list()[0]
+    assert mr.rebase()
+
+
+@pytest.mark.gitlab_premium
+@pytest.mark.xfail(reason="project /approvers endpoint is gone")
+def test_project_approvals(project):
+    mr = project.mergerequests.list()[0]
+    approval = project.approvals.get()
+
+    reset_value = approval.reset_approvals_on_push
+    approval.reset_approvals_on_push = not reset_value
+    approval.save()
+
+    approval = project.approvals.get()
+    assert reset_value != approval.reset_approvals_on_push
+
+    project.approvals.set_approvers([1], [])
+    approval = project.approvals.get()
+    assert approval.approvers[0]["user"]["id"] == 1
+
+    approval = mr.approvals.get()
+    approval.approvals_required = 2
+    approval.save()
+    approval = mr.approvals.get()
+    assert approval.approvals_required == 2
+
+    approval.approvals_required = 3
+    approval.save()
+    approval = mr.approvals.get()
+    assert approval.approvals_required == 3
+
+    mr.approvals.set_approvers(1, [1], [])
+    approval = mr.approvals.get()
+    assert approval.approvers[0]["user"]["id"] == 1
+
+
+@pytest.mark.gitlab_premium
+def test_project_merge_request_approval_rules(group, project):
+    approval_rules = project.approvalrules.list(get_all=True)
+    assert not approval_rules
+
+    project.approvalrules.create(
+        {"name": "approval-rule", "approvals_required": 2, "group_ids": [group.id]}
+    )
+    approval_rules = project.approvalrules.list(get_all=True)
+    assert len(approval_rules) == 1
+    assert approval_rules[0].approvals_required == 2
+
+    approval_rules[0].save()
+    approval_rules = project.approvalrules.list(get_all=True)
+    assert len(approval_rules) == 1
+    assert approval_rules[0].approvals_required == 2
+
+    approval_rules[0].delete()
+
+
+def test_merge_request_reset_approvals(gitlab_url, project):
+    today = datetime.date.today()
+    future_date = today + datetime.timedelta(days=4)
+    bot = project.access_tokens.create(
+        {"name": "bot", "scopes": ["api"], "expires_at": future_date.isoformat()}
+    )
+
+    bot_gitlab = gitlab.Gitlab(gitlab_url, private_token=bot.token)
+    bot_project = bot_gitlab.projects.get(project.id, lazy=True)
+
+    # Pause to let GL catch up (happens on hosted too, sometimes takes a while for server to be ready to merge)
+    time.sleep(5)
+
+    mr = bot_project.mergerequests.list()[0]
+
+    assert mr.reset_approvals()
+
+
+def test_cancel_merge_when_pipeline_succeeds(project, merge_request_with_pipeline):
+    # Pause to let GL catch up (happens on hosted too, sometimes takes a while for server to be ready to merge)
+    time.sleep(5)
+    # Set to merge when the pipeline succeeds, which should never happen
+    merge_request_with_pipeline.merge(merge_when_pipeline_succeeds=True)
+    # Pause to let GL catch up (happens on hosted too, sometimes takes a while for server to be ready to merge)
+    time.sleep(5)
+
+    mr = project.mergerequests.get(merge_request_with_pipeline.iid)
+    assert mr.merged_at is None
+    assert mr.merge_when_pipeline_succeeds is True
+    cancel = mr.cancel_merge_when_pipeline_succeeds()
+    assert cancel == {"status": "success"}
+
+
+def test_merge_request_merge(project, merge_request):
+    merge_request.merge()
+    # Pause to let GL catch up (happens on hosted too, sometimes takes a while for server to be ready to merge)
+    time.sleep(5)
+
+    mr = project.mergerequests.get(merge_request.iid)
+    assert mr.merged_at is not None
+    assert mr.merge_when_pipeline_succeeds is False
+    with pytest.raises(gitlab.GitlabMRClosedError):
+        # Two merge attempts should raise GitlabMRClosedError
+        mr.merge()
+
+
+def test_merge_request_should_remove_source_branch(project, merge_request) -> None:
+    """Test to ensure
+    https://github.com/python-gitlab/python-gitlab/issues/1120 is fixed.
+    Bug reported that they could not use 'should_remove_source_branch' in
+    mr.merge() call"""
+    merge_request.merge(should_remove_source_branch=True)
+    # Pause to let GL catch up (happens on hosted too, sometimes takes a while for server to be ready to merge)
+    time.sleep(5)
+
+    # Wait until it is merged
+    mr = None
+    mr_iid = merge_request.iid
+    for _ in range(60):
+        mr = project.mergerequests.get(mr_iid)
+        if mr.merged_at is not None:
+            break
+        time.sleep(0.5)
+
+    assert mr is not None
+    assert mr.merged_at is not None
+    time.sleep(0.5)
+    # Pause to let GL catch up (happens on hosted too, sometimes takes a while for server to be ready to merge)
+    time.sleep(5)
+
+    # Ensure we can NOT get the MR branch
+    with pytest.raises(gitlab.exceptions.GitlabGetError):
+        result = project.branches.get(merge_request.source_branch)
+        # Help to debug in case the expected exception doesn't happen.
+        import pprint
+
+        print("mr:", pprint.pformat(mr))
+        print("mr.merged_at:", pprint.pformat(mr.merged_at))
+        print("result:", pprint.pformat(result))
+
+
+def test_merge_request_large_commit_message(project, merge_request) -> None:
+    """Test to ensure https://github.com/python-gitlab/python-gitlab/issues/1452
+    is fixed.
+    Bug reported that very long 'merge_commit_message' in mr.merge() would
+    cause an error: 414 Request too large
+    """
+    merge_commit_message = "large_message\r\n" * 1_000
+    assert len(merge_commit_message) > 10_000
+
+    merge_request.merge(
+        merge_commit_message=merge_commit_message, should_remove_source_branch=False
+    )
+
+    # Pause to let GL catch up (happens on hosted too, sometimes takes a while for server to be ready to merge)
+    time.sleep(5)
+
+    # Wait until it is merged
+    mr = None
+    mr_iid = merge_request.iid
+    for _ in range(60):
+        mr = project.mergerequests.get(mr_iid)
+        if mr.merged_at is not None:
+            break
+        time.sleep(0.5)
+
+    assert mr is not None
+    assert mr.merged_at is not None
+    time.sleep(0.5)
+
+    # Ensure we can get the MR branch
+    project.branches.get(merge_request.source_branch)
+
+
+def test_merge_request_merge_ref(merge_request) -> None:
+    response = merge_request.merge_ref()
+    assert response and "commit_id" in response
+
+
+def test_merge_request_merge_ref_should_fail(project, merge_request) -> None:
+    # Create conflict
+    project.files.create(
+        {
+            "file_path": f"README.{merge_request.source_branch}",
+            "branch": project.default_branch,
+            "content": "Different initial content",
+            "commit_message": "Another commit in main branch",
+        }
+    )
+    # Pause to let GL catch up (happens on hosted too, sometimes takes a while for server to be ready to merge)
+    time.sleep(5)
+
+    # Check for non-existing merge_ref for MR with conflicts
+    with pytest.raises(gitlab.exceptions.GitlabGetError):
+        response = merge_request.merge_ref()
+        assert "commit_id" not in response
diff --git a/tests/functional/api/test_packages.py b/tests/functional/api/test_packages.py
new file mode 100644
index 000000000..37c9d2f55
--- /dev/null
+++ b/tests/functional/api/test_packages.py
@@ -0,0 +1,179 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ce/api/packages.html
+https://docs.gitlab.com/ee/user/packages/generic_packages
+"""
+
+from collections.abc import Iterator
+
+import pytest
+
+from gitlab import Gitlab
+from gitlab.v4.objects import GenericPackage, Project, ProjectPackageProtectionRule
+
+package_name = "hello-world"
+package_version = "v1.0.0"
+file_name = "hello.tar.gz"
+file_name2 = "hello2.tar.gz"
+file_content = "package content"
+
+
+@pytest.fixture(scope="module", autouse=True)
+def protected_package_feature(gl: Gitlab):
+    gl.features.set(name="packages_protected_packages", value=True)
+
+
+def test_list_project_packages(project):
+    packages = project.packages.list()
+    assert isinstance(packages, list)
+
+
+def test_list_group_packages(group):
+    packages = group.packages.list()
+    assert isinstance(packages, list)
+
+
+def test_upload_generic_package(tmp_path, project):
+    path = tmp_path / file_name
+    path.write_text(file_content)
+    package = project.generic_packages.upload(
+        package_name=package_name,
+        package_version=package_version,
+        file_name=file_name,
+        path=path,
+    )
+
+    assert isinstance(package, GenericPackage)
+    assert package.message == "201 Created"
+
+
+def test_upload_generic_package_as_bytes(tmp_path, project):
+    path = tmp_path / file_name
+
+    path.write_text(file_content)
+
+    package = project.generic_packages.upload(
+        package_name=package_name,
+        package_version=package_version,
+        file_name=file_name,
+        data=path.read_bytes(),
+    )
+
+    assert isinstance(package, GenericPackage)
+    assert package.message == "201 Created"
+
+
+def test_upload_generic_package_as_file(tmp_path, project):
+    path = tmp_path / file_name
+
+    path.write_text(file_content)
+
+    package = project.generic_packages.upload(
+        package_name=package_name,
+        package_version=package_version,
+        file_name=file_name,
+        data=path.open(mode="rb"),
+    )
+
+    assert isinstance(package, GenericPackage)
+    assert package.message == "201 Created"
+
+
+def test_upload_generic_package_select(tmp_path, project):
+    path = tmp_path / file_name2
+    path.write_text(file_content)
+    package = project.generic_packages.upload(
+        package_name=package_name,
+        package_version=package_version,
+        file_name=file_name2,
+        path=path,
+        select="package_file",
+    )
+
+    assert isinstance(package, GenericPackage)
+    assert package.file_name == file_name2
+    assert package.size == path.stat().st_size
+
+
+def test_download_generic_package(project):
+    package = project.generic_packages.download(
+        package_name=package_name, package_version=package_version, file_name=file_name
+    )
+
+    assert isinstance(package, bytes)
+    assert package.decode("utf-8") == file_content
+
+
+def test_stream_generic_package(project):
+    bytes_iterator = project.generic_packages.download(
+        package_name=package_name,
+        package_version=package_version,
+        file_name=file_name,
+        iterator=True,
+    )
+
+    assert isinstance(bytes_iterator, Iterator)
+
+    package = b""
+    for chunk in bytes_iterator:
+        package += chunk
+
+    assert isinstance(package, bytes)
+    assert package.decode("utf-8") == file_content
+
+
+def test_download_generic_package_to_file(tmp_path, project):
+    path = tmp_path / file_name
+
+    with open(path, "wb") as f:
+        project.generic_packages.download(
+            package_name=package_name,
+            package_version=package_version,
+            file_name=file_name,
+            streamed=True,
+            action=f.write,
+        )
+
+    with open(path) as f:
+        assert f.read() == file_content
+
+
+def test_stream_generic_package_to_file(tmp_path, project):
+    path = tmp_path / file_name
+
+    bytes_iterator = project.generic_packages.download(
+        package_name=package_name,
+        package_version=package_version,
+        file_name=file_name,
+        iterator=True,
+    )
+
+    with open(path, "wb") as f:
+        for chunk in bytes_iterator:
+            f.write(chunk)
+
+    with open(path) as f:
+        assert f.read() == file_content
+
+
+def test_list_project_protected_packages(project: Project):
+    rules = project.package_protection_rules.list()
+    assert isinstance(rules, list)
+
+
+@pytest.mark.skip(reason="Not released yet")
+def test_create_project_protected_packages(project: Project):
+    protected_package = project.package_protection_rules.create(
+        {
+            "package_name_pattern": "v*",
+            "package_type": "npm",
+            "minimum_access_level_for_push": "maintainer",
+        }
+    )
+    assert isinstance(protected_package, ProjectPackageProtectionRule)
+    assert protected_package.package_type == "npm"
+
+    protected_package.minimum_access_level_for_push = "owner"
+    protected_package.save()
+
+    protected_package.delete()
diff --git a/tests/functional/api/test_project_job_token_scope.py b/tests/functional/api/test_project_job_token_scope.py
new file mode 100644
index 000000000..0d0466182
--- /dev/null
+++ b/tests/functional/api/test_project_job_token_scope.py
@@ -0,0 +1,116 @@
+# https://docs.gitlab.com/ee/ci/jobs/ci_job_token.html#allow-any-project-to-access-your-project
+def test_enable_limit_access_to_this_project(gl, project):
+    scope = project.job_token_scope.get()
+
+    scope.enabled = True
+    scope.save()
+
+    scope.refresh()
+
+    assert scope.inbound_enabled
+
+
+def test_disable_limit_access_to_this_project(gl, project):
+    scope = project.job_token_scope.get()
+
+    scope.enabled = False
+    scope.save()
+
+    scope.refresh()
+
+    assert not scope.inbound_enabled
+
+
+def test_add_project_to_job_token_scope_allowlist(gl, project):
+    project_to_add = gl.projects.create({"name": "Ci_Cd_token_add_proj"})
+
+    scope = project.job_token_scope.get()
+    resp = scope.allowlist.create({"target_project_id": project_to_add.id})
+
+    assert resp.source_project_id == project.id
+    assert resp.target_project_id == project_to_add.id
+
+    project_to_add.delete()
+
+
+def test_projects_job_token_scope_allowlist_contains_added_project_name(gl, project):
+    scope = project.job_token_scope.get()
+    project_name = "Ci_Cd_token_named_proj"
+    project_to_add = gl.projects.create({"name": project_name})
+    scope.allowlist.create({"target_project_id": project_to_add.id})
+
+    scope.refresh()
+    assert any(allowed.name == project_name for allowed in scope.allowlist.list())
+
+    project_to_add.delete()
+
+
+def test_remove_project_by_id_from_projects_job_token_scope_allowlist(gl, project):
+    scope = project.job_token_scope.get()
+
+    project_to_add = gl.projects.create({"name": "Ci_Cd_token_remove_proj"})
+
+    scope.allowlist.create({"target_project_id": project_to_add.id})
+
+    scope.refresh()
+
+    scope.allowlist.delete(project_to_add.id)
+
+    scope.refresh()
+    assert not any(
+        allowed.id == project_to_add.id for allowed in scope.allowlist.list()
+    )
+
+    project_to_add.delete()
+
+
+def test_add_group_to_job_token_scope_allowlist(gl, project):
+    group_to_add = gl.groups.create(
+        {"name": "add_group", "path": "allowlisted-add-test"}
+    )
+
+    scope = project.job_token_scope.get()
+    resp = scope.groups_allowlist.create({"target_group_id": group_to_add.id})
+
+    assert resp.source_project_id == project.id
+    assert resp.target_group_id == group_to_add.id
+
+    group_to_add.delete()
+
+
+def test_projects_job_token_scope_groups_allowlist_contains_added_group_name(
+    gl, project
+):
+    scope = project.job_token_scope.get()
+    group_name = "list_group"
+    group_to_add = gl.groups.create(
+        {"name": group_name, "path": "allowlisted-add-and-list-test"}
+    )
+
+    scope.groups_allowlist.create({"target_group_id": group_to_add.id})
+
+    scope.refresh()
+    assert any(allowed.name == group_name for allowed in scope.groups_allowlist.list())
+
+    group_to_add.delete()
+
+
+def test_remove_group_by_id_from_projects_job_token_scope_groups_allowlist(gl, project):
+    scope = project.job_token_scope.get()
+
+    group_to_add = gl.groups.create(
+        {"name": "delete_group", "path": "allowlisted-delete-test"}
+    )
+
+    scope.groups_allowlist.create({"target_group_id": group_to_add.id})
+
+    scope.refresh()
+
+    scope.groups_allowlist.delete(group_to_add.id)
+
+    scope.refresh()
+    assert not any(
+        allowed.name == group_to_add.name for allowed in scope.groups_allowlist.list()
+    )
+
+    group_to_add.delete()
diff --git a/tests/functional/api/test_projects.py b/tests/functional/api/test_projects.py
new file mode 100644
index 000000000..760f95336
--- /dev/null
+++ b/tests/functional/api/test_projects.py
@@ -0,0 +1,446 @@
+import time
+import uuid
+
+import pytest
+
+import gitlab
+from gitlab.const import AccessLevel
+from gitlab.v4.objects.projects import ProjectStorage
+
+
+def test_projects_head(gl):
+    headers = gl.projects.head()
+    assert headers["x-total"]
+
+
+def test_project_head(gl, project):
+    headers = gl.projects.head(project.id)
+    assert headers["content-type"] == "application/json"
+
+
+def test_create_project(gl, user):
+    # Moved from group tests chunk in legacy tests, TODO cleanup
+    admin_project = gl.projects.create({"name": "admin_project"})
+    assert isinstance(admin_project, gitlab.v4.objects.Project)
+    assert admin_project in gl.projects.list(search="admin_project")
+
+    sudo_project = gl.projects.create({"name": "sudo_project"}, sudo=user.id)
+
+    created = gl.projects.list()
+    created_gen = gl.projects.list(iterator=True)
+    owned = gl.projects.list(owned=True)
+
+    assert admin_project in created and sudo_project in created
+    assert admin_project in owned and sudo_project not in owned
+    assert len(created) == len(list(created_gen))
+
+    admin_project.delete()
+    sudo_project.delete()
+
+
+def test_project_members(user, project):
+    member = project.members.create(
+        {"user_id": user.id, "access_level": AccessLevel.DEVELOPER}
+    )
+    assert member in project.members.list()
+    assert member.access_level == 30
+
+    member.delete()
+
+
+def test_project_avatar_upload(gl, project, fixture_dir):
+    """Test uploading an avatar to a project."""
+    with open(fixture_dir / "avatar.png", "rb") as avatar_file:
+        project.avatar = avatar_file
+        project.save()
+
+    updated_project = gl.projects.get(project.id)
+    assert updated_project.avatar_url is not None
+
+
+def test_project_avatar_remove(gl, project, fixture_dir):
+    """Test removing an avatar from a project."""
+    with open(fixture_dir / "avatar.png", "rb") as avatar_file:
+        project.avatar = avatar_file
+        project.save()
+
+    project.avatar = ""
+    project.save()
+
+    updated_project = gl.projects.get(project.id)
+    assert updated_project.avatar_url is None
+
+
+def test_project_badges(project):
+    badge_image = "http://example.com"
+    badge_link = "http://example/img.svg"
+
+    badge = project.badges.create({"link_url": badge_link, "image_url": badge_image})
+    assert badge in project.badges.list()
+
+    badge.image_url = "http://another.example.com"
+    badge.save()
+
+    badge = project.badges.get(badge.id)
+    assert badge.image_url == "http://another.example.com"
+
+    badge.delete()
+
+
+@pytest.mark.skip(reason="Commented out in legacy test")
+def test_project_boards(project):
+    boards = project.boards.list()
+    assert boards
+
+    board = boards[0]
+    lists = board.lists.list()
+
+    last_list = lists[-1]
+    last_list.position = 0
+    last_list.save()
+
+    last_list.delete()
+
+
+def test_project_custom_attributes(gl, project):
+    attrs = project.customattributes.list()
+    assert not attrs
+
+    attr = project.customattributes.set("key", "value1")
+    assert attr.key == "key"
+    assert attr.value == "value1"
+    assert attr in project.customattributes.list()
+    assert project in gl.projects.list(custom_attributes={"key": "value1"})
+
+    attr = project.customattributes.set("key", "value2")
+    attr = project.customattributes.get("key")
+    assert attr.value == "value2"
+    assert attr in project.customattributes.list()
+
+    attr.delete()
+
+
+def test_project_environments(project):
+    environment = project.environments.create(
+        {"name": "env1", "external_url": "http://fake.env/whatever"}
+    )
+    environments = project.environments.list()
+    assert environment in environments
+
+    environment = environments[0]
+    environment.external_url = "http://new.env/whatever"
+    environment.save()
+
+    environment = project.environments.list()[0]
+    assert environment.external_url == "http://new.env/whatever"
+
+    environment.stop()
+
+    environment.delete()
+
+
+def test_project_events(project):
+    events = project.events.list()
+    assert isinstance(events, list)
+
+
+def test_project_file_uploads(project):
+    filename = "test.txt"
+    file_contents = "testing contents"
+
+    uploaded_file = project.upload(filename, file_contents)
+    alt, url = uploaded_file["alt"], uploaded_file["url"]
+    assert alt == filename
+    assert url.startswith("/uploads/")
+    assert url.endswith(f"/{filename}")
+    assert uploaded_file["markdown"] == f"[{alt}]({url})"
+
+
+def test_project_forks(gl, project, user):
+    fork = project.forks.create({"namespace": user.username})
+    fork_project = gl.projects.get(fork.id)
+    assert fork_project.forked_from_project["id"] == project.id
+
+    forks = project.forks.list()
+    assert fork.id in [fork_project.id for fork_project in forks]
+
+
+def test_project_hooks(project):
+    hook = project.hooks.create({"url": "http://hook.url"})
+    assert hook in project.hooks.list()
+
+    hook.note_events = True
+    hook.save()
+
+    hook = project.hooks.get(hook.id)
+    assert hook.note_events is True
+
+    hook.delete()
+
+
+def test_project_housekeeping(project):
+    project.housekeeping()
+
+
+def test_project_labels(project):
+    label = project.labels.create({"name": "label", "color": "#778899"})
+    labels = project.labels.list()
+    assert label in labels
+
+    label = project.labels.get("label")
+    assert label == labels[0]
+
+    label.new_name = "Label:that requires:encoding"
+    label.save()
+    assert label.name == "Label:that requires:encoding"
+    label = project.labels.get("Label:that requires:encoding")
+    assert label.name == "Label:that requires:encoding"
+
+    label.subscribe()
+    assert label.subscribed is True
+
+    label.unsubscribe()
+    assert label.subscribed is False
+
+    label.delete()
+
+
+def test_project_label_promotion(gl, group):
+    """
+    Label promotion requires the project to be a child of a group (not in a user namespace)
+
+    """
+    _id = uuid.uuid4().hex
+    data = {"name": f"test-project-{_id}", "namespace_id": group.id}
+    project = gl.projects.create(data)
+
+    label_name = "promoteme"
+    promoted_label = project.labels.create({"name": label_name, "color": "#112233"})
+    promoted_label.promote()
+
+    assert any(label.name == label_name for label in group.labels.list())
+
+    group.labels.delete(label_name)
+
+
+def test_project_milestones(project):
+    milestone = project.milestones.create({"title": "milestone1"})
+    assert milestone in project.milestones.list()
+
+    milestone.due_date = "2020-01-01T00:00:00Z"
+    milestone.save()
+
+    milestone.state_event = "close"
+    milestone.save()
+
+    milestone = project.milestones.get(milestone.id)
+    assert milestone.state == "closed"
+    assert not milestone.issues()
+    assert not milestone.merge_requests()
+
+
+def test_project_milestone_promotion(gl, group):
+    """
+    Milestone promotion requires the project to be a child of a group (not in a user namespace)
+
+    """
+    _id = uuid.uuid4().hex
+    data = {"name": f"test-project-{_id}", "namespace_id": group.id}
+    project = gl.projects.create(data)
+
+    milestone_title = "promoteme"
+    promoted_milestone = project.milestones.create({"title": milestone_title})
+    promoted_milestone.promote()
+
+    assert any(
+        milestone.title == milestone_title for milestone in group.milestones.list()
+    )
+
+
+def test_project_pages(project):
+    pages = project.pages.get()
+    assert pages.is_unique_domain_enabled is True
+
+    project.pages.update(new_data={"pages_unique_domain_enabled": False})
+
+    pages.refresh()
+    assert pages.is_unique_domain_enabled is False
+
+    project.pages.delete()
+
+
+def test_project_pages_domains(gl, project):
+    domain = project.pagesdomains.create({"domain": "foo.domain.com"})
+    assert domain in project.pagesdomains.list()
+    assert domain in gl.pagesdomains.list()
+
+    domain = project.pagesdomains.get("foo.domain.com")
+    assert domain.domain == "foo.domain.com"
+
+    domain.delete()
+
+
+def test_project_protected_branches(project, gitlab_version):
+    # Updating a protected branch is possible from Gitlab 15.6
+    # https://docs.gitlab.com/ee/api/protected_branches.html#update-a-protected-branch
+    can_update_prot_branch = gitlab_version.major > 15 or (
+        gitlab_version.major == 15 and gitlab_version.minor >= 6
+    )
+
+    p_b = project.protectedbranches.create(
+        {"name": "*-stable", "allow_force_push": False}
+    )
+    assert p_b.name == "*-stable"
+    assert not p_b.allow_force_push
+    assert p_b in project.protectedbranches.list()
+
+    if can_update_prot_branch:
+        p_b.allow_force_push = True
+        p_b.save()
+        # Pause to let GL catch up (happens on hosted too, sometimes takes a while for server to be ready to merge)
+        time.sleep(5)
+
+    p_b = project.protectedbranches.get("*-stable")
+    if can_update_prot_branch:
+        assert p_b.allow_force_push
+
+        p_b.delete()
+
+
+def test_project_remote_mirrors(project):
+    mirror_url = "https://gitlab.example.com/root/mirror.git"
+
+    mirror = project.remote_mirrors.create({"url": mirror_url})
+    assert mirror.url == mirror_url
+
+    mirror.enabled = True
+    mirror.save()
+
+    mirror = project.remote_mirrors.list()[0]
+    assert isinstance(mirror, gitlab.v4.objects.ProjectRemoteMirror)
+    assert mirror.url == mirror_url
+    assert mirror.enabled is True
+
+    mirror.delete()
+
+
+def test_project_pull_mirrors(project):
+    mirror_url = "https://gitlab.example.com/root/mirror.git"
+
+    mirror = project.pull_mirror.create({"url": mirror_url})
+    assert mirror.url == mirror_url
+
+    mirror.enabled = True
+    mirror.save()
+
+    mirror = project.pull_mirror.get()
+    assert isinstance(mirror, gitlab.v4.objects.ProjectPullMirror)
+    assert mirror.url == mirror_url
+    assert mirror.enabled is True
+
+    mirror.enabled = False
+    mirror.save()
+
+
+def test_project_services(project):
+    # Use 'update' to create a service as we don't have a 'create' method and
+    # to add one is somewhat complicated so it hasn't been done yet.
+    project.services.update("asana", api_key="foo")
+
+    service = project.services.get("asana")
+    assert service.active is True
+    service.api_key = "whatever"
+    service.save()
+
+    service = project.services.get("asana")
+    assert service.active is True
+
+    service.delete()
+
+
+def test_project_stars(project):
+    project.star()
+    assert project.star_count == 1
+
+    project.unstar()
+    assert project.star_count == 0
+
+
+def test_project_storage(project):
+    storage = project.storage.get()
+    assert isinstance(storage, ProjectStorage)
+    assert storage.repository_storage == "default"
+
+
+def test_project_tags(project, project_file):
+    tag = project.tags.create({"tag_name": "v1.0", "ref": "main"})
+    assert tag in project.tags.list()
+
+    tag.delete()
+
+
+def test_project_triggers(project):
+    trigger = project.triggers.create({"description": "trigger1"})
+    assert trigger in project.triggers.list()
+
+    trigger.delete()
+
+
+def test_project_wiki(project):
+    content = "Wiki page content"
+    wiki = project.wikis.create({"title": "wikipage", "content": content})
+    assert wiki in project.wikis.list()
+
+    wiki = project.wikis.get(wiki.slug)
+    assert wiki.content == content
+
+    # update and delete seem broken
+    wiki.content = "new content"
+    wiki.save()
+
+    wiki.delete()
+
+
+def test_project_groups_list(gl, group):
+    """Test listing groups of a project"""
+    # Create a subgroup of our top-group, we will place our new project inside
+    # this group.
+    group2 = gl.groups.create(
+        {"name": "group2_proj", "path": "group2_proj", "parent_id": group.id}
+    )
+    data = {"name": "test-project-tpsg", "namespace_id": group2.id}
+    project = gl.projects.create(data)
+
+    groups = project.groups.list()
+    group_ids = {x.id for x in groups}
+    assert {group.id, group2.id} == group_ids
+
+
+def test_project_transfer(gl, project, group):
+    assert project.namespace["path"] != group.full_path
+    project.transfer(group.id)
+
+    project = gl.projects.get(project.id)
+    assert project.namespace["path"] == group.full_path
+
+    gl.auth()
+    project.transfer(gl.user.username)
+
+    project = gl.projects.get(project.id)
+    assert project.namespace["path"] == gl.user.username
+
+
+@pytest.mark.gitlab_premium
+def test_project_external_status_check_create(gl, project):
+    status_check = project.external_status_checks.create(
+        {"name": "MR blocker", "external_url": "https://example.com/mr-blocker"}
+    )
+    assert status_check.name == "MR blocker"
+    assert status_check.external_url == "https://example.com/mr-blocker"
+
+
+@pytest.mark.gitlab_premium
+def test_project_external_status_check_list(gl, project):
+    status_checks = project.external_status_checks.list()
+
+    assert len(status_checks) == 1
diff --git a/tests/functional/api/test_push_rules.py b/tests/functional/api/test_push_rules.py
new file mode 100644
index 000000000..15a31403c
--- /dev/null
+++ b/tests/functional/api/test_push_rules.py
@@ -0,0 +1,26 @@
+import pytest
+
+import gitlab
+
+
+@pytest.mark.gitlab_premium
+def test_project_push_rules(project):
+    with pytest.raises(gitlab.GitlabParsingError):
+        # when no rules are defined the API call returns back `None` which
+        # causes a gitlab.GitlabParsingError in RESTObject.__init__()
+        project.pushrules.get()
+
+    push_rules = project.pushrules.create({"deny_delete_tag": True})
+    assert push_rules.deny_delete_tag
+
+    push_rules.deny_delete_tag = False
+    push_rules.save()
+
+    push_rules = project.pushrules.get()
+    assert push_rules
+    assert not push_rules.deny_delete_tag
+
+    push_rules.delete()
+
+    with pytest.raises(gitlab.GitlabParsingError):
+        project.pushrules.get()
diff --git a/tests/functional/api/test_registry.py b/tests/functional/api/test_registry.py
new file mode 100644
index 000000000..91fdceacc
--- /dev/null
+++ b/tests/functional/api/test_registry.py
@@ -0,0 +1,28 @@
+import pytest
+
+from gitlab import Gitlab
+from gitlab.v4.objects import Project, ProjectRegistryProtectionRule
+
+
+@pytest.fixture(scope="module", autouse=True)
+def protected_registry_feature(gl: Gitlab):
+    gl.features.set(name="container_registry_protected_containers", value=True)
+
+
+@pytest.mark.skip(reason="Not released yet")
+def test_project_protected_registry(project: Project):
+    rules = project.registry_protection_repository_rules.list()
+    assert isinstance(rules, list)
+
+    protected_registry = project.registry_protection_repository_rules.create(
+        {
+            "repository_path_pattern": "test/image",
+            "minimum_access_level_for_push": "maintainer",
+        }
+    )
+    assert isinstance(protected_registry, ProjectRegistryProtectionRule)
+    assert protected_registry.repository_path_pattern == "test/image"
+
+    protected_registry.minimum_access_level_for_push = "owner"
+    protected_registry.save()
+    assert protected_registry.minimum_access_level_for_push == "owner"
diff --git a/tests/functional/api/test_releases.py b/tests/functional/api/test_releases.py
new file mode 100644
index 000000000..33b059c04
--- /dev/null
+++ b/tests/functional/api/test_releases.py
@@ -0,0 +1,62 @@
+release_name = "Demo Release"
+release_tag_name = "v1.2.3"
+release_description = "release notes go here"
+
+link_data = {"url": "https://example.com", "name": "link_name"}
+
+
+def test_create_project_release(project, project_file):
+    project.refresh()  # Gets us the current default branch
+    release = project.releases.create(
+        {
+            "name": release_name,
+            "tag_name": release_tag_name,
+            "description": release_description,
+            "ref": project.default_branch,
+        }
+    )
+
+    assert release in project.releases.list()
+    assert project.releases.get(release_tag_name)
+    assert release.name == release_name
+    assert release.tag_name == release_tag_name
+    assert release.description == release_description
+
+
+def test_create_project_release_no_name(project, project_file):
+    unnamed_release_tag_name = "v2.3.4"
+
+    project.refresh()  # Gets us the current default branch
+    release = project.releases.create(
+        {
+            "tag_name": unnamed_release_tag_name,
+            "description": release_description,
+            "ref": project.default_branch,
+        }
+    )
+
+    assert release in project.releases.list()
+    assert project.releases.get(unnamed_release_tag_name)
+    assert release.tag_name == unnamed_release_tag_name
+    assert release.description == release_description
+
+
+def test_update_save_project_release(project, release):
+    updated_description = f"{release.description} updated"
+    release.description = updated_description
+    release.save()
+
+    release = project.releases.get(release.tag_name)
+    assert release.description == updated_description
+
+
+def test_delete_project_release(project, release):
+    project.releases.delete(release.tag_name)
+
+
+def test_create_project_release_links(project, release):
+    release.links.create(link_data)
+
+    release = project.releases.get(release.tag_name)
+    assert release.assets["links"][0]["url"] == link_data["url"]
+    assert release.assets["links"][0]["name"] == link_data["name"]
diff --git a/tests/functional/api/test_repository.py b/tests/functional/api/test_repository.py
new file mode 100644
index 000000000..b2520f0bf
--- /dev/null
+++ b/tests/functional/api/test_repository.py
@@ -0,0 +1,200 @@
+import base64
+import os
+import tarfile
+import time
+import zipfile
+from io import BytesIO
+
+import pytest
+
+import gitlab
+
+
+def test_repository_files(project):
+    project.files.create(
+        {
+            "file_path": "README.md",
+            "branch": "main",
+            "content": "Initial content",
+            "commit_message": "Initial commit",
+        }
+    )
+    readme = project.files.get(file_path="README.md", ref="main")
+    readme.content = base64.b64encode(b"Improved README").decode()
+
+    time.sleep(2)
+    readme.save(branch="main", commit_message="new commit")
+    readme.delete(commit_message="Removing README", branch="main")
+
+    project.files.create(
+        {
+            "file_path": "README.rst",
+            "branch": "main",
+            "content": "Initial content",
+            "commit_message": "New commit",
+        }
+    )
+    readme = project.files.get(file_path="README.rst", ref="main")
+    # The first decode() is the ProjectFile method, the second one is the bytes
+    # object method
+    assert readme.decode().decode() == "Initial content"
+
+    headers = project.files.head("README.rst", ref="main")
+    assert headers["X-Gitlab-File-Path"] == "README.rst"
+
+    blame = project.files.blame(file_path="README.rst", ref="main")
+    assert blame
+
+    raw_file = project.files.raw(file_path="README.rst", ref="main")
+    assert os.fsdecode(raw_file) == "Initial content"
+
+    raw_file = project.files.raw(file_path="README.rst")
+    assert os.fsdecode(raw_file) == "Initial content"
+
+
+def test_repository_tree(project):
+    tree = project.repository_tree()
+    assert tree
+    assert tree[0]["name"] == "README.rst"
+
+    blob_id = tree[0]["id"]
+    blob = project.repository_raw_blob(blob_id)
+    assert blob.decode() == "Initial content"
+
+    snapshot = project.snapshot()
+    assert isinstance(snapshot, bytes)
+
+
+def test_repository_archive(project):
+    archive = project.repository_archive()
+    assert isinstance(archive, bytes)
+
+    archive2 = project.repository_archive("main")
+    assert archive == archive2
+
+
+@pytest.mark.parametrize(
+    "format,assertion",
+    [
+        ("tbz", tarfile.is_tarfile),
+        ("tbz2", tarfile.is_tarfile),
+        ("tb2", tarfile.is_tarfile),
+        ("bz2", tarfile.is_tarfile),
+        ("tar", tarfile.is_tarfile),
+        ("tar.gz", tarfile.is_tarfile),
+        ("tar.bz2", tarfile.is_tarfile),
+        ("zip", zipfile.is_zipfile),
+    ],
+)
+def test_repository_archive_formats(project, format, assertion):
+    archive = project.repository_archive(format=format)
+    assert assertion(BytesIO(archive))
+
+
+def test_create_commit(project):
+    data = {
+        "branch": "main",
+        "commit_message": "blah blah blah",
+        "actions": [{"action": "create", "file_path": "blah", "content": "blah"}],
+    }
+    commit = project.commits.create(data)
+
+    assert "@@" in project.commits.list()[0].diff()[0]["diff"]
+    assert isinstance(commit.refs(), list)
+    assert isinstance(commit.merge_requests(), list)
+
+
+def test_list_all_commits(project):
+    data = {
+        "branch": "new-branch",
+        "start_branch": "main",
+        "commit_message": "New commit on new branch",
+        "actions": [
+            {"action": "create", "file_path": "new-file", "content": "new content"}
+        ],
+    }
+    commit = project.commits.create(data)
+
+    commits = project.commits.list(all=True)
+    assert commit not in commits
+
+    # Listing commits on other branches requires `all` parameter passed to the API
+    all_commits = project.commits.list(get_all=True, all=True)
+    assert commit in all_commits
+    assert len(all_commits) > len(commits)
+
+
+def test_create_commit_status(project):
+    commit = project.commits.list()[0]
+    status = commit.statuses.create({"state": "success", "sha": commit.id})
+    assert status in commit.statuses.list()
+
+
+def test_commit_signature(project):
+    commit = project.commits.list()[0]
+
+    with pytest.raises(gitlab.GitlabGetError) as e:
+        commit.signature()
+
+    assert "404 Signature Not Found" in str(e.value)
+
+
+def test_commit_comment(project):
+    commit = project.commits.list()[0]
+
+    commit.comments.create({"note": "This is a commit comment"})
+    assert len(commit.comments.list()) == 1
+
+
+def test_commit_discussion(project):
+    commit = project.commits.list()[0]
+
+    discussion = commit.discussions.create({"body": "Discussion body"})
+    assert discussion in commit.discussions.list()
+
+    note = discussion.notes.create({"body": "first note"})
+    note_from_get = discussion.notes.get(note.id)
+    note_from_get.body = "updated body"
+    note_from_get.save()
+    discussion = commit.discussions.get(discussion.id)
+
+    note_from_get.delete()
+
+
+def test_cherry_pick_commit(project):
+    commits = project.commits.list()
+    commit = commits[1]
+    parent_commit = commit.parent_ids[0]
+
+    # create a branch to cherry pick onto
+    project.branches.create({"branch": "test", "ref": parent_commit})
+    cherry_pick_commit = commit.cherry_pick(branch="test")
+
+    expected_message = f"{commit.message}\n\n(cherry picked from commit {commit.id})"
+    assert cherry_pick_commit["message"].startswith(expected_message)
+
+    with pytest.raises(gitlab.GitlabCherryPickError):
+        # Two cherry pick attempts should raise GitlabCherryPickError
+        commit.cherry_pick(branch="test")
+
+
+def test_revert_commit(project):
+    commit = project.commits.list()[0]
+    revert_commit = commit.revert(branch="main")
+
+    expected_message = f'Revert "{commit.message}"\n\nThis reverts commit {commit.id}'
+    assert revert_commit["message"] == expected_message
+
+    with pytest.raises(gitlab.GitlabRevertError):
+        # Two revert attempts should raise GitlabRevertError
+        commit.revert(branch="main")
+
+
+def test_repository_merge_base(project):
+    refs = [commit.id for commit in project.commits.list(all=True)]
+
+    commit = project.repository_merge_base(refs)
+    assert commit["id"] in refs
+
+    with pytest.raises(gitlab.GitlabGetError, match="Provide at least 2 refs"):
+        commit = project.repository_merge_base(refs[0])
diff --git a/tests/functional/api/test_services.py b/tests/functional/api/test_services.py
new file mode 100644
index 000000000..ce9503080
--- /dev/null
+++ b/tests/functional/api/test_services.py
@@ -0,0 +1,36 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/integrations.html
+"""
+
+import gitlab
+
+
+def test_get_service_lazy(project):
+    service = project.services.get("jira", lazy=True)
+    assert isinstance(service, gitlab.v4.objects.ProjectService)
+
+
+def test_update_service(project):
+    service_dict = project.services.update(
+        "emails-on-push", {"recipients": "email@example.com"}
+    )
+    assert service_dict["active"]
+
+
+def test_list_services(project, service):
+    services = project.services.list()
+    assert isinstance(services[0], gitlab.v4.objects.ProjectService)
+    assert services[0].active
+
+
+def test_get_service(project, service):
+    service_object = project.services.get(service["slug"])
+    assert isinstance(service_object, gitlab.v4.objects.ProjectService)
+    assert service_object.active
+
+
+def test_delete_service(project, service):
+    service_object = project.services.get(service["slug"])
+
+    service_object.delete()
diff --git a/tests/functional/api/test_snippets.py b/tests/functional/api/test_snippets.py
new file mode 100644
index 000000000..41a888d7d
--- /dev/null
+++ b/tests/functional/api/test_snippets.py
@@ -0,0 +1,90 @@
+import pytest
+
+import gitlab
+
+
+def test_snippets(gl):
+    snippets = gl.snippets.list(get_all=True)
+    assert not snippets
+
+    snippet = gl.snippets.create(
+        {
+            "title": "snippet1",
+            "files": [{"file_path": "snippet1.py", "content": "import gitlab"}],
+        }
+    )
+    snippet = gl.snippets.get(snippet.id)
+    snippet.title = "updated_title"
+    snippet.save()
+
+    snippet = gl.snippets.get(snippet.id)
+    assert snippet.title == "updated_title"
+
+    content = snippet.content()
+    assert content.decode() == "import gitlab"
+
+    all_snippets = gl.snippets.list_all(get_all=True)
+    with pytest.warns(
+        DeprecationWarning, match=r"Gitlab.snippets.public\(\) is deprecated"
+    ):
+        public_snippets = gl.snippets.public(get_all=True)
+    list_public_snippets = gl.snippets.list_public(get_all=True)
+    assert isinstance(all_snippets, list)
+    assert isinstance(list_public_snippets, list)
+    assert public_snippets == list_public_snippets
+
+    snippet.delete()
+
+
+def test_project_snippets(project):
+    project.snippets_enabled = True
+    project.save()
+
+    snippet = project.snippets.create(
+        {
+            "title": "snip1",
+            "files": [{"file_path": "foo.py", "content": "initial content"}],
+            "visibility": gitlab.const.VISIBILITY_PRIVATE,
+        }
+    )
+
+    assert snippet.title == "snip1"
+
+
+@pytest.mark.xfail(reason="Returning 404 UserAgentDetail not found in GL 16")
+def test_project_snippet_user_agent_detail(project):
+    snippet = project.snippets.list()[0]
+
+    user_agent_detail = snippet.user_agent_detail()
+
+    assert user_agent_detail["user_agent"]
+
+
+def test_project_snippet_discussion(project):
+    snippet = project.snippets.list()[0]
+
+    discussion = snippet.discussions.create({"body": "Discussion body"})
+    assert discussion in snippet.discussions.list()
+
+    note = discussion.notes.create({"body": "first note"})
+    note_from_get = discussion.notes.get(note.id)
+    note_from_get.body = "updated body"
+    note_from_get.save()
+
+    discussion = snippet.discussions.get(discussion.id)
+    assert discussion.attributes["notes"][-1]["body"] == "updated body"
+
+    note_from_get.delete()
+
+
+def test_project_snippet_file(project):
+    snippet = project.snippets.list()[0]
+    snippet.file_name = "bar.py"
+    snippet.save()
+
+    snippet = project.snippets.get(snippet.id)
+    assert snippet.content().decode() == "initial content"
+    assert snippet.file_name == "bar.py"
+    assert snippet in project.snippets.list()
+
+    snippet.delete()
diff --git a/tests/functional/api/test_statistics.py b/tests/functional/api/test_statistics.py
new file mode 100644
index 000000000..ee0f4a96e
--- /dev/null
+++ b/tests/functional/api/test_statistics.py
@@ -0,0 +1,12 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/statistics.html
+"""
+
+
+def test_get_statistics(gl):
+    statistics = gl.statistics.get()
+
+    assert statistics.snippets.isdigit()
+    assert statistics.users.isdigit()
+    assert statistics.groups.isdigit()
+    assert statistics.projects.isdigit()
diff --git a/tests/functional/api/test_topics.py b/tests/functional/api/test_topics.py
new file mode 100644
index 000000000..0ac318458
--- /dev/null
+++ b/tests/functional/api/test_topics.py
@@ -0,0 +1,78 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ce/api/topics.html
+"""
+
+
+def test_topics(gl, gitlab_version):
+    assert not gl.topics.list()
+
+    create_dict = {"name": "my-topic", "description": "My Topic"}
+    if gitlab_version.major >= 15:
+        create_dict["title"] = "my topic title"
+    topic = gl.topics.create(create_dict)
+    assert topic.name == "my-topic"
+
+    if gitlab_version.major >= 15:
+        assert topic.title == "my topic title"
+
+    assert gl.topics.list()
+
+    topic.description = "My Updated Topic"
+    topic.save()
+    updated_topic = gl.topics.get(topic.id)
+    assert updated_topic.description == topic.description
+
+    create_dict = {"name": "my-second-topic", "description": "My Second Topic"}
+    if gitlab_version.major >= 15:
+        create_dict["title"] = "my second topic title"
+    topic2 = gl.topics.create(create_dict)
+    merged_topic = gl.topics.merge(topic.id, topic2.id)
+    assert merged_topic["id"] == topic2.id
+
+    topic2.delete()
+
+
+def test_topic_avatar_upload(gl, fixture_dir):
+    """Test uploading an avatar to a topic."""
+
+    topic = gl.topics.create(
+        {
+            "name": "avatar-topic",
+            "description": "Topic with avatar",
+            "title": "Avatar Topic",
+        }
+    )
+
+    with open(fixture_dir / "avatar.png", "rb") as avatar_file:
+        topic.avatar = avatar_file
+        topic.save()
+
+    updated_topic = gl.topics.get(topic.id)
+    assert updated_topic.avatar_url is not None
+
+    topic.delete()
+
+
+def test_topic_avatar_remove(gl, fixture_dir):
+    """Test removing an avatar from a topic."""
+
+    topic = gl.topics.create(
+        {
+            "name": "avatar-topic-remove",
+            "description": "Remove avatar",
+            "title": "Remove Avatar",
+        }
+    )
+
+    with open(fixture_dir / "avatar.png", "rb") as avatar_file:
+        topic.avatar = avatar_file
+        topic.save()
+
+    topic.avatar = ""
+    topic.save()
+
+    updated_topic = gl.topics.get(topic.id)
+    assert updated_topic.avatar_url is None
+
+    topic.delete()
diff --git a/tests/functional/api/test_users.py b/tests/functional/api/test_users.py
new file mode 100644
index 000000000..58c90c646
--- /dev/null
+++ b/tests/functional/api/test_users.py
@@ -0,0 +1,190 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/users.html
+https://docs.gitlab.com/ee/api/users.html#delete-authentication-identity-from-user
+"""
+
+import datetime
+import time
+
+import requests
+
+
+def test_create_user(gl, fixture_dir):
+    user = gl.users.create(
+        {
+            "email": "foo@bar.com",
+            "username": "foo",
+            "name": "foo",
+            "password": "E4596f8be406Bc3a14a4ccdb1df80587$3",
+            "avatar": open(fixture_dir / "avatar.png", "rb"),
+        }
+    )
+
+    created_user = gl.users.list(username="foo")[0]
+    assert created_user.username == user.username
+    assert created_user.email == user.email
+
+    avatar_url = user.avatar_url.replace("gitlab.test", "localhost:8080")
+    uploaded_avatar = requests.get(avatar_url).content
+    with open(fixture_dir / "avatar.png", "rb") as f:
+        assert uploaded_avatar == f.read()
+
+
+def test_block_user(gl, user):
+    result = user.block()
+    assert result is True
+    users = gl.users.list(blocked=True)
+    assert user in users
+
+    # block again
+    result = user.block()
+    # Trying to block an already blocked user returns None
+    assert result is None
+
+    result = user.unblock()
+    assert result is True
+    users = gl.users.list(blocked=False)
+    assert user in users
+
+    # unblock again
+    result = user.unblock()
+    # Trying to unblock an already blocked user returns False
+    assert result is False
+
+
+def test_ban_user(gl, user):
+    user.ban()
+    retrieved_user = gl.users.get(user.id)
+    assert retrieved_user.state == "banned"
+
+    user.unban()
+    retrieved_user = gl.users.get(user.id)
+    assert retrieved_user.state == "active"
+
+
+def test_delete_user(gl):
+    new_user = gl.users.create(
+        {
+            "email": "delete-user@test.com",
+            "username": "delete-user",
+            "name": "delete-user",
+            "password": "E4596f8be406Bc3a14a4ccdb1df80587#15",
+        }
+    )
+
+    # We don't need to validate Gitlab's behaviour by checking if user is present after a delay etc,
+    # just that python-gitlab acted correctly to produce a 2xx from Gitlab
+
+    new_user.delete()
+
+
+def test_user_projects_list(gl, user):
+    projects = user.projects.list()
+    assert isinstance(projects, list)
+    assert not projects
+
+
+def test_user_events_list(gl, user):
+    events = user.events.list()
+    assert isinstance(events, list)
+    assert not events
+
+
+def test_user_bio(gl, user):
+    user.bio = "This is the user bio"
+    user.save()
+
+
+def test_list_multiple_users(gl, user):
+    second_email = f"{user.email}.2"
+    second_username = f"{user.username}_2"
+    second_user = gl.users.create(
+        {
+            "email": second_email,
+            "username": second_username,
+            "name": "Foo Bar",
+            "password": "E4596f8be406Bc3a14a4ccdb1df80587#!",
+        }
+    )
+    assert gl.users.list(search=second_user.username)[0].id == second_user.id
+
+    expected = [user, second_user]
+    actual = list(gl.users.list(search=user.username))
+
+    assert set(expected) == set(actual)
+    assert not gl.users.list(search="asdf")
+
+
+def test_user_gpg_keys(gl, user, GPG_KEY):
+    gkey = user.gpgkeys.create({"key": GPG_KEY})
+    assert gkey in user.gpgkeys.list()
+
+    gkey.delete()
+
+
+def test_user_ssh_keys(gl, user, SSH_KEY):
+    key = user.keys.create({"title": "testkey", "key": SSH_KEY})
+    assert key in user.keys.list()
+
+    get_key = user.keys.get(key.id)
+    assert get_key.key == key.key
+
+    key.delete()
+
+
+def test_user_email(gl, user):
+    email = user.emails.create({"email": "foo2@bar.com"})
+    assert email in user.emails.list()
+
+    email.delete()
+
+
+def test_user_custom_attributes(gl, user):
+    user.customattributes.list()
+
+    attr = user.customattributes.set("key", "value1")
+    users_with_attribute = gl.users.list(custom_attributes={"key": "value1"})
+
+    assert user in users_with_attribute
+
+    assert attr.key == "key"
+    assert attr.value == "value1"
+    assert attr in user.customattributes.list()
+
+    user.customattributes.set("key", "value2")
+    attr_2 = user.customattributes.get("key")
+    assert attr_2.value == "value2"
+    assert attr_2 in user.customattributes.list()
+
+    attr_2.delete()
+
+
+def test_user_impersonation_tokens(gl, user):
+    today = datetime.date.today()
+    future_date = today + datetime.timedelta(days=4)
+
+    token = user.impersonationtokens.create(
+        {
+            "name": "user_impersonation_token",
+            "scopes": ["api", "read_user"],
+            "expires_at": future_date.isoformat(),
+        }
+    )
+    # Pause to let GL catch up (happens on hosted too, sometimes takes a while for server to be ready to merge)
+    time.sleep(30)
+
+    assert token in user.impersonationtokens.list(state="active")
+
+    token.delete()
+
+
+def test_user_identities(gl, user):
+    provider = "test_provider"
+
+    user.provider = provider
+    user.extern_uid = "1"
+    user.save()
+    assert provider in [item["provider"] for item in user.identities]
+
+    user.identityproviders.delete(provider)
diff --git a/tests/functional/api/test_variables.py b/tests/functional/api/test_variables.py
new file mode 100644
index 000000000..eeed51da7
--- /dev/null
+++ b/tests/functional/api/test_variables.py
@@ -0,0 +1,45 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/instance_level_ci_variables.html
+https://docs.gitlab.com/ee/api/project_level_variables.html
+https://docs.gitlab.com/ee/api/group_level_variables.html
+"""
+
+
+def test_instance_variables(gl):
+    variable = gl.variables.create({"key": "key1", "value": "value1"})
+    assert variable.value == "value1"
+    assert variable in gl.variables.list()
+
+    variable.value = "new_value1"
+    variable.save()
+    variable = gl.variables.get(variable.key)
+    assert variable.value == "new_value1"
+
+    variable.delete()
+
+
+def test_group_variables(group):
+    variable = group.variables.create({"key": "key1", "value": "value1"})
+    assert variable.value == "value1"
+    assert variable in group.variables.list()
+
+    variable.value = "new_value1"
+    variable.save()
+    variable = group.variables.get(variable.key)
+    assert variable.value == "new_value1"
+
+    variable.delete()
+
+
+def test_project_variables(project):
+    variable = project.variables.create({"key": "key1", "value": "value1"})
+    assert variable.value == "value1"
+    assert variable in project.variables.list()
+
+    variable.value = "new_value1"
+    variable.save()
+    variable = project.variables.get(variable.key)
+    assert variable.value == "new_value1"
+
+    variable.delete()
diff --git a/tests/functional/api/test_wikis.py b/tests/functional/api/test_wikis.py
new file mode 100644
index 000000000..0a84e5737
--- /dev/null
+++ b/tests/functional/api/test_wikis.py
@@ -0,0 +1,62 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/wikis.html
+"""
+
+
+def test_project_wikis(project):
+    page = project.wikis.create({"title": "title/subtitle", "content": "test content"})
+    page.content = "update content"
+    page.title = "subtitle"
+
+    page.save()
+
+    page.delete()
+
+
+def test_project_wiki_file_upload(project):
+    page = project.wikis.create(
+        {"title": "title/subtitle", "content": "test page content"}
+    )
+    filename = "test.txt"
+    file_contents = "testing contents"
+
+    uploaded_file = page.upload(filename, file_contents)
+
+    link = uploaded_file["link"]
+    file_name = uploaded_file["file_name"]
+    file_path = uploaded_file["file_path"]
+    assert file_name == filename
+    assert file_path.startswith("uploads/")
+    assert file_path.endswith(f"/{filename}")
+    assert link["url"] == file_path
+    assert link["markdown"] == f"[{file_name}]({file_path})"
+
+
+def test_group_wikis(group):
+    page = group.wikis.create({"title": "title/subtitle", "content": "test content"})
+    page.content = "update content"
+    page.title = "subtitle"
+
+    page.save()
+
+    page.delete()
+
+
+def test_group_wiki_file_upload(group):
+    page = group.wikis.create(
+        {"title": "title/subtitle", "content": "test page content"}
+    )
+    filename = "test.txt"
+    file_contents = "testing contents"
+
+    uploaded_file = page.upload(filename, file_contents)
+
+    link = uploaded_file["link"]
+    file_name = uploaded_file["file_name"]
+    file_path = uploaded_file["file_path"]
+    assert file_name == filename
+    assert file_path.startswith("uploads/")
+    assert file_path.endswith(f"/{filename}")
+    assert link["url"] == file_path
+    assert link["markdown"] == f"[{file_name}]({file_path})"
diff --git a/tests/functional/cli/__init__.py b/tests/functional/cli/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/functional/cli/conftest.py b/tests/functional/cli/conftest.py
new file mode 100644
index 000000000..f695c098b
--- /dev/null
+++ b/tests/functional/cli/conftest.py
@@ -0,0 +1,55 @@
+import pytest
+import responses
+
+from gitlab.const import DEFAULT_URL
+
+
+@pytest.fixture
+def gitlab_cli(script_runner, gitlab_config):
+    """Wrapper fixture to help make test cases less verbose."""
+
+    def _gitlab_cli(subcommands):
+        """
+        Return a script_runner.run method that takes a default gitlab
+        command, and subcommands passed as arguments inside test cases.
+        """
+        command = ["gitlab", "--config-file", gitlab_config]
+
+        for subcommand in subcommands:
+            # ensure we get strings (e.g from IDs)
+            command.append(str(subcommand))
+
+        return script_runner.run(command)
+
+    return _gitlab_cli
+
+
+@pytest.fixture
+def resp_get_project():
+    return {
+        "method": responses.GET,
+        "url": f"{DEFAULT_URL}/api/v4/projects/1",
+        "json": {"name": "name", "path": "test-path", "id": 1},
+        "content_type": "application/json",
+        "status": 200,
+    }
+
+
+@pytest.fixture
+def resp_current_user():
+    return {
+        "method": responses.GET,
+        "url": f"{DEFAULT_URL}/api/v4/user",
+        "json": {"username": "name", "id": 1},
+        "content_type": "application/json",
+        "status": 200,
+    }
+
+
+@pytest.fixture
+def resp_delete_registry_tags_in_bulk():
+    return {
+        "method": responses.DELETE,
+        "url": f"{DEFAULT_URL}/api/v4/projects/1/registry/repositories/1/tags",
+        "status": 202,
+    }
diff --git a/tests/functional/cli/test_cli.py b/tests/functional/cli/test_cli.py
new file mode 100644
index 000000000..d82728f9d
--- /dev/null
+++ b/tests/functional/cli/test_cli.py
@@ -0,0 +1,211 @@
+"""
+Some test cases are run in-process to intercept requests to gitlab.com
+and example servers.
+"""
+
+import copy
+import json
+
+import pytest
+import responses
+import yaml
+
+from gitlab import __version__, config
+from gitlab.const import DEFAULT_URL
+
+PRIVATE_TOKEN = "glpat-abc123"
+CI_JOB_TOKEN = "ci-job-token"
+CI_SERVER_URL = "https://gitlab.example.com"
+
+
+def test_main_entrypoint(script_runner, gitlab_config):
+    ret = script_runner.run(["python", "-m", "gitlab", "--config-file", gitlab_config])
+    assert ret.returncode == 2
+
+
+def test_version(script_runner):
+    ret = script_runner.run(["gitlab", "--version"])
+    assert ret.stdout.strip() == __version__
+
+
+def test_config_error_with_help_prints_help(script_runner):
+    ret = script_runner.run(["gitlab", "-c", "invalid-file", "--help"])
+    assert ret.stdout.startswith("usage:")
+    assert ret.returncode == 0
+
+
+def test_resource_help_prints_actions_vertically(script_runner):
+    ret = script_runner.run(["gitlab", "project", "--help"])
+    assert "    list                List the GitLab resources\n" in ret.stdout
+    assert "    get                 Get a GitLab resource\n" in ret.stdout
+    assert ret.returncode == 0
+
+
+def test_resource_help_prints_actions_vertically_only_one_action(script_runner):
+    ret = script_runner.run(["gitlab", "event", "--help"])
+    assert "  {list}      Action to execute on the GitLab resource.\n"
+    assert "    list      List the GitLab resources\n" in ret.stdout
+    assert ret.returncode == 0
+
+
+@pytest.mark.script_launch_mode("inprocess")
+@responses.activate
+def test_defaults_to_gitlab_com(script_runner, resp_get_project, monkeypatch):
+    responses.add(**resp_get_project)
+    monkeypatch.setattr(config, "_DEFAULT_FILES", [])
+    ret = script_runner.run(["gitlab", "project", "get", "--id", "1"])
+    assert ret.success
+    assert "id: 1" in ret.stdout
+
+
+@pytest.mark.script_launch_mode("inprocess")
+@responses.activate
+def test_uses_ci_server_url(monkeypatch, script_runner, resp_get_project):
+    monkeypatch.setenv("CI_SERVER_URL", CI_SERVER_URL)
+    monkeypatch.setattr(config, "_DEFAULT_FILES", [])
+    resp_get_project_in_ci = copy.deepcopy(resp_get_project)
+    resp_get_project_in_ci.update(url=f"{CI_SERVER_URL}/api/v4/projects/1")
+
+    responses.add(**resp_get_project_in_ci)
+    ret = script_runner.run(["gitlab", "project", "get", "--id", "1"])
+    assert ret.success
+
+
+@pytest.mark.script_launch_mode("inprocess")
+@responses.activate
+def test_uses_ci_job_token(monkeypatch, script_runner, resp_get_project):
+    monkeypatch.setenv("CI_JOB_TOKEN", CI_JOB_TOKEN)
+    monkeypatch.setattr(config, "_DEFAULT_FILES", [])
+    resp_get_project_in_ci = copy.deepcopy(resp_get_project)
+    resp_get_project_in_ci.update(
+        match=[responses.matchers.header_matcher({"JOB-TOKEN": CI_JOB_TOKEN})]
+    )
+
+    responses.add(**resp_get_project_in_ci)
+    ret = script_runner.run(["gitlab", "project", "get", "--id", "1"])
+    assert ret.success
+
+
+@pytest.mark.script_launch_mode("inprocess")
+@responses.activate
+def test_does_not_auth_on_skip_login(
+    monkeypatch, script_runner, resp_get_project, resp_current_user
+):
+    monkeypatch.setenv("GITLAB_PRIVATE_TOKEN", PRIVATE_TOKEN)
+    monkeypatch.setattr(config, "_DEFAULT_FILES", [])
+
+    resp_user = responses.add(**resp_current_user)
+    resp_project = responses.add(**resp_get_project)
+    ret = script_runner.run(["gitlab", "--skip-login", "project", "get", "--id", "1"])
+    assert ret.success
+    assert resp_user.call_count == 0
+    assert resp_project.call_count == 1
+
+
+@pytest.mark.script_launch_mode("inprocess")
+@responses.activate
+def test_private_token_overrides_job_token(
+    monkeypatch, script_runner, resp_get_project
+):
+    monkeypatch.setenv("GITLAB_PRIVATE_TOKEN", PRIVATE_TOKEN)
+    monkeypatch.setenv("CI_JOB_TOKEN", CI_JOB_TOKEN)
+
+    resp_get_project_with_token = copy.deepcopy(resp_get_project)
+    resp_get_project_with_token.update(
+        match=[responses.matchers.header_matcher({"PRIVATE-TOKEN": PRIVATE_TOKEN})]
+    )
+
+    # CLI first calls .auth() when private token is present
+    resp_auth_with_token = copy.deepcopy(resp_get_project_with_token)
+    resp_auth_with_token.update(url=f"{DEFAULT_URL}/api/v4/user")
+    resp_auth_with_token["json"].update(username="user", web_url=f"{DEFAULT_URL}/user")
+
+    responses.add(**resp_get_project_with_token)
+    responses.add(**resp_auth_with_token)
+    ret = script_runner.run(["gitlab", "project", "get", "--id", "1"])
+    assert ret.success
+
+
+def test_env_config_missing_file_raises(script_runner, monkeypatch):
+    monkeypatch.setenv("PYTHON_GITLAB_CFG", "non-existent")
+    ret = script_runner.run(["gitlab", "project", "list"])
+    assert not ret.success
+    assert ret.stderr.startswith("Cannot read config from PYTHON_GITLAB_CFG")
+
+
+def test_arg_config_missing_file_raises(script_runner):
+    ret = script_runner.run(
+        ["gitlab", "--config-file", "non-existent", "project", "list"]
+    )
+    assert not ret.success
+    assert ret.stderr.startswith("Cannot read config from file")
+
+
+def test_invalid_config(script_runner):
+    ret = script_runner.run(["gitlab", "--gitlab", "invalid"])
+    assert not ret.success
+    assert not ret.stdout
+
+
+def test_invalid_config_prints_help(script_runner):
+    ret = script_runner.run(["gitlab", "--gitlab", "invalid", "--help"])
+    assert ret.success
+    assert ret.stdout
+
+
+def test_invalid_api_version(script_runner, monkeypatch, fixture_dir):
+    monkeypatch.setenv("PYTHON_GITLAB_CFG", str(fixture_dir / "invalid_version.cfg"))
+    ret = script_runner.run(["gitlab", "--gitlab", "test", "project", "list"])
+    assert not ret.success
+    assert ret.stderr.startswith("Unsupported API version:")
+
+
+def test_invalid_auth_config(script_runner, monkeypatch, fixture_dir):
+    monkeypatch.setenv("PYTHON_GITLAB_CFG", str(fixture_dir / "invalid_auth.cfg"))
+    ret = script_runner.run(["gitlab", "--gitlab", "test", "project", "list"])
+    assert not ret.success
+    assert "401" in ret.stderr
+
+
+format_matrix = [("json", json.loads), ("yaml", yaml.safe_load)]
+
+
+@pytest.mark.parametrize("format,loader", format_matrix)
+def test_cli_display(gitlab_cli, project, format, loader):
+    cmd = ["-o", format, "project", "get", "--id", project.id]
+
+    ret = gitlab_cli(cmd)
+    assert ret.success
+
+    content = loader(ret.stdout.strip())
+    assert content["id"] == project.id
+
+
+@pytest.mark.parametrize("format,loader", format_matrix)
+def test_cli_fields_in_list(gitlab_cli, project_file, format, loader):
+    cmd = ["-o", format, "--fields", "default_branch", "project", "list"]
+
+    ret = gitlab_cli(cmd)
+    assert ret.success
+
+    content = loader(ret.stdout.strip())
+    assert ["default_branch" in item for item in content]
+
+
+def test_cli_display_without_fields_warns(gitlab_cli, project):
+    cmd = ["project-ci-lint", "get", "--project-id", project.id]
+
+    ret = gitlab_cli(cmd)
+    assert ret.success
+
+    assert "No default fields to show" in ret.stdout
+    assert "merged_yaml" not in ret.stdout
+
+
+def test_cli_does_not_print_token(gitlab_cli, gitlab_token):
+    ret = gitlab_cli(["--debug", "current-user", "get"])
+    assert ret.success
+
+    assert gitlab_token not in ret.stdout
+    assert gitlab_token not in ret.stderr
+    assert "[MASKED]" in ret.stderr
diff --git a/tests/functional/cli/test_cli_artifacts.py b/tests/functional/cli/test_cli_artifacts.py
new file mode 100644
index 000000000..589486844
--- /dev/null
+++ b/tests/functional/cli/test_cli_artifacts.py
@@ -0,0 +1,110 @@
+import logging
+import subprocess
+import textwrap
+import time
+from io import BytesIO
+from zipfile import is_zipfile
+
+import pytest
+
+content = textwrap.dedent(
+    """\
+    test-artifact:
+      script: echo "test" > artifact.txt
+      artifacts:
+        untracked: true
+    """
+)
+data = {
+    "file_path": ".gitlab-ci.yml",
+    "branch": "main",
+    "content": content,
+    "commit_message": "Initial commit",
+}
+
+
+@pytest.fixture(scope="module")
+def job_with_artifacts(gitlab_runner, project):
+    start_time = time.time()
+
+    project.files.create(data)
+
+    jobs = None
+    while not jobs:
+        time.sleep(0.5)
+        jobs = project.jobs.list(scope="success")
+        if time.time() - start_time < 60:
+            continue
+        logging.error("job never succeeded")
+        for job in project.jobs.list():
+            job = project.jobs.get(job.id)
+            logging.info(f"{job.status} job: {job.pformat()}")
+            logging.info(f"job log:\n{job.trace()}\n")
+        pytest.fail("Fixture 'job_with_artifact' failed")
+
+    return project.jobs.get(jobs[0].id)
+
+
+def test_cli_job_artifacts(capsysbinary, gitlab_config, job_with_artifacts):
+    cmd = [
+        "gitlab",
+        "--config-file",
+        gitlab_config,
+        "project-job",
+        "artifacts",
+        "--id",
+        str(job_with_artifacts.id),
+        "--project-id",
+        str(job_with_artifacts.pipeline["project_id"]),
+    ]
+
+    with capsysbinary.disabled():
+        artifacts = subprocess.check_output(cmd)
+    assert isinstance(artifacts, bytes)
+
+    artifacts_zip = BytesIO(artifacts)
+    assert is_zipfile(artifacts_zip)
+
+
+def test_cli_project_artifact_download(gitlab_config, job_with_artifacts):
+    cmd = [
+        "gitlab",
+        "--config-file",
+        gitlab_config,
+        "project-artifact",
+        "download",
+        "--project-id",
+        str(job_with_artifacts.pipeline["project_id"]),
+        "--ref-name",
+        job_with_artifacts.ref,
+        "--job",
+        job_with_artifacts.name,
+    ]
+
+    artifacts = subprocess.run(cmd, capture_output=True, check=True)
+    assert isinstance(artifacts.stdout, bytes)
+
+    artifacts_zip = BytesIO(artifacts.stdout)
+    assert is_zipfile(artifacts_zip)
+
+
+def test_cli_project_artifact_raw(gitlab_config, job_with_artifacts):
+    cmd = [
+        "gitlab",
+        "--config-file",
+        gitlab_config,
+        "project-artifact",
+        "raw",
+        "--project-id",
+        str(job_with_artifacts.pipeline["project_id"]),
+        "--ref-name",
+        job_with_artifacts.ref,
+        "--job",
+        job_with_artifacts.name,
+        "--artifact-path",
+        "artifact.txt",
+    ]
+
+    artifacts = subprocess.run(cmd, capture_output=True, check=True)
+    assert isinstance(artifacts.stdout, bytes)
+    assert artifacts.stdout == b"test\n"
diff --git a/tests/functional/cli/test_cli_files.py b/tests/functional/cli/test_cli_files.py
new file mode 100644
index 000000000..405fbb21b
--- /dev/null
+++ b/tests/functional/cli/test_cli_files.py
@@ -0,0 +1,21 @@
+def test_project_file_raw(gitlab_cli, project, project_file):
+    cmd = ["project-file", "raw", "--project-id", project.id, "--file-path", "README"]
+    ret = gitlab_cli(cmd)
+    assert ret.success
+    assert "Initial content" in ret.stdout
+
+
+def test_project_file_raw_ref(gitlab_cli, project, project_file):
+    cmd = [
+        "project-file",
+        "raw",
+        "--project-id",
+        project.id,
+        "--file-path",
+        "README",
+        "--ref",
+        "main",
+    ]
+    ret = gitlab_cli(cmd)
+    assert ret.success
+    assert "Initial content" in ret.stdout
diff --git a/tests/functional/cli/test_cli_packages.py b/tests/functional/cli/test_cli_packages.py
new file mode 100644
index 000000000..d7cdd18cb
--- /dev/null
+++ b/tests/functional/cli/test_cli_packages.py
@@ -0,0 +1,60 @@
+package_name = "hello-world"
+package_version = "v1.0.0"
+file_name = "hello.tar.gz"
+file_content = "package content"
+
+
+def test_list_project_packages(gitlab_cli, project):
+    cmd = ["project-package", "list", "--project-id", project.id]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_list_group_packages(gitlab_cli, group):
+    cmd = ["group-package", "list", "--group-id", group.id]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_upload_generic_package(tmp_path, gitlab_cli, project):
+    path = tmp_path / file_name
+    path.write_text(file_content)
+
+    cmd = [
+        "-v",
+        "generic-package",
+        "upload",
+        "--project-id",
+        project.id,
+        "--package-name",
+        package_name,
+        "--path",
+        path,
+        "--package-version",
+        package_version,
+        "--file-name",
+        file_name,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert "201 Created" in ret.stdout
+
+
+def test_download_generic_package(gitlab_cli, project):
+    cmd = [
+        "generic-package",
+        "download",
+        "--project-id",
+        project.id,
+        "--package-name",
+        package_name,
+        "--package-version",
+        package_version,
+        "--file-name",
+        file_name,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.stdout == file_content
diff --git a/tests/functional/cli/test_cli_projects.py b/tests/functional/cli/test_cli_projects.py
new file mode 100644
index 000000000..1d11e265f
--- /dev/null
+++ b/tests/functional/cli/test_cli_projects.py
@@ -0,0 +1,69 @@
+import subprocess
+import time
+
+import pytest
+import responses
+
+
+@pytest.mark.script_launch_mode("inprocess")
+@responses.activate
+def test_project_registry_delete_in_bulk(
+    script_runner, resp_delete_registry_tags_in_bulk
+):
+    responses.add(**resp_delete_registry_tags_in_bulk)
+    cmd = [
+        "gitlab",
+        "project-registry-tag",
+        "delete-in-bulk",
+        "--project-id",
+        "1",
+        "--repository-id",
+        "1",
+        "--name-regex-delete",
+        "^.*dev.*$",
+        # TODO: remove `name` after deleting without ID is possible
+        # See #849 and #1631
+        "--name",
+        ".*",
+    ]
+    ret = ret = script_runner.run(cmd)
+    assert ret.success
+
+
+@pytest.fixture
+def project_export(project):
+    export = project.exports.create()
+    export.refresh()
+
+    count = 0
+    while export.export_status != "finished":
+        time.sleep(0.5)
+        export.refresh()
+        count += 1
+        if count >= 60:
+            raise Exception("Project export taking too much time")
+
+    return export
+
+
+def test_project_export_download_custom_action(gitlab_config, project_export):
+    """Tests custom action on ProjectManager"""
+    cmd = [
+        "gitlab",
+        "--config-file",
+        gitlab_config,
+        "project-export",
+        "download",
+        "--project-id",
+        str(project_export.id),
+    ]
+
+    export = subprocess.run(cmd, capture_output=True, check=True)
+    assert export.returncode == 0
+
+
+def test_project_languages_custom_action(gitlab_cli, project, project_file):
+    """Tests custom action on Project/RESTObject"""
+    cmd = ["project", "languages", "--id", project.id]
+    ret = gitlab_cli(cmd)
+    assert ret.success
diff --git a/tests/functional/cli/test_cli_repository.py b/tests/functional/cli/test_cli_repository.py
new file mode 100644
index 000000000..d6bd1d2e4
--- /dev/null
+++ b/tests/functional/cli/test_cli_repository.py
@@ -0,0 +1,151 @@
+import json
+import time
+
+
+def test_project_create_file(gitlab_cli, project):
+    file_path = "README"
+    branch = "main"
+    content = "CONTENT"
+    commit_message = "Initial commit"
+
+    cmd = [
+        "project-file",
+        "create",
+        "--project-id",
+        project.id,
+        "--file-path",
+        file_path,
+        "--branch",
+        branch,
+        "--content",
+        content,
+        "--commit-message",
+        commit_message,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_list_all_commits(gitlab_cli, project):
+    data = {
+        "branch": "new-branch",
+        "start_branch": "main",
+        "commit_message": "chore: test commit on new branch",
+        "actions": [
+            {
+                "action": "create",
+                "file_path": "test-cli-repo.md",
+                "content": "new content",
+            }
+        ],
+    }
+    commit = project.commits.create(data)
+
+    cmd = ["project-commit", "list", "--project-id", project.id, "--get-all"]
+    ret = gitlab_cli(cmd)
+    assert commit.id not in ret.stdout
+
+    # Listing commits on other branches requires `all` parameter passed to the API
+    cmd = [
+        "project-commit",
+        "list",
+        "--project-id",
+        project.id,
+        "--get-all",
+        "--all",
+        "true",
+    ]
+    ret_all = gitlab_cli(cmd)
+    assert commit.id in ret_all.stdout
+    assert len(ret_all.stdout) > len(ret.stdout)
+
+
+def test_list_merge_request_commits(gitlab_cli, merge_request, project):
+    cmd = [
+        "project-merge-request",
+        "commits",
+        "--project-id",
+        project.id,
+        "--iid",
+        merge_request.iid,
+    ]
+
+    ret = gitlab_cli(cmd)
+    assert ret.success
+    assert ret.stdout
+
+
+def test_commit_merge_requests(gitlab_cli, project, merge_request):
+    """This tests the `project-commit merge-requests` command and also tests
+    that we can print the result using the `json` formatter"""
+
+    # Pause to let GL catch up (happens on hosted too, sometimes takes a while for server to be ready to merge)
+    time.sleep(30)
+
+    merge_result = merge_request.merge(should_remove_source_branch=True)
+    # Pause to let GL catch up (happens on hosted too, sometimes takes a while for server to be ready to merge)
+    time.sleep(5)
+
+    # Wait until it is merged
+    mr = None
+    mr_iid = merge_request.iid
+    for _ in range(60):
+        mr = project.mergerequests.get(mr_iid)
+        if mr.merged_at is not None:
+            break
+        time.sleep(0.5)
+
+    assert mr is not None
+    assert mr.merged_at is not None
+    # Pause to let GL catch up (happens on hosted too, sometimes takes a while for server to be ready to merge)
+    time.sleep(5)
+
+    commit_sha = merge_result["sha"]
+    cmd = [
+        "-o",
+        "json",
+        "project-commit",
+        "merge-requests",
+        "--project-id",
+        project.id,
+        "--id",
+        commit_sha,
+    ]
+    ret = gitlab_cli(cmd)
+    assert ret.success
+
+    json_list = json.loads(ret.stdout)
+    assert isinstance(json_list, list)
+    assert len(json_list) == 1
+    mr_dict = json_list[0]
+    assert mr_dict["id"] == mr.id
+    assert mr_dict["iid"] == mr.iid
+
+
+def test_revert_commit(gitlab_cli, project):
+    commit = project.commits.list()[0]
+
+    cmd = [
+        "project-commit",
+        "revert",
+        "--project-id",
+        project.id,
+        "--id",
+        commit.id,
+        "--branch",
+        "main",
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_get_commit_signature_not_found(gitlab_cli, project):
+    commit = project.commits.list()[0]
+
+    cmd = ["project-commit", "signature", "--project-id", project.id, "--id", commit.id]
+    ret = gitlab_cli(cmd)
+
+    assert not ret.success
+    assert "404 Signature Not Found" in ret.stderr
diff --git a/tests/functional/cli/test_cli_resource_access_tokens.py b/tests/functional/cli/test_cli_resource_access_tokens.py
new file mode 100644
index 000000000..c080749b5
--- /dev/null
+++ b/tests/functional/cli/test_cli_resource_access_tokens.py
@@ -0,0 +1,51 @@
+import datetime
+
+
+def test_list_project_access_tokens(gitlab_cli, project):
+    cmd = ["project-access-token", "list", "--project-id", project.id]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_create_project_access_token_with_scopes(gitlab_cli, project):
+    cmd = [
+        "project-access-token",
+        "create",
+        "--project-id",
+        project.id,
+        "--name",
+        "test-token",
+        "--scopes",
+        "api,read_repository",
+        "--expires-at",
+        datetime.date.today().isoformat(),
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_list_group_access_tokens(gitlab_cli, group):
+    cmd = ["group-access-token", "list", "--group-id", group.id]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_create_group_access_token_with_scopes(gitlab_cli, group):
+    cmd = [
+        "group-access-token",
+        "create",
+        "--group-id",
+        group.id,
+        "--name",
+        "test-token",
+        "--scopes",
+        "api,read_repository",
+        "--expires-at",
+        datetime.date.today().isoformat(),
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
diff --git a/tests/functional/cli/test_cli_users.py b/tests/functional/cli/test_cli_users.py
new file mode 100644
index 000000000..fd1942ae1
--- /dev/null
+++ b/tests/functional/cli/test_cli_users.py
@@ -0,0 +1,26 @@
+import datetime
+
+
+def test_create_user_impersonation_token_with_scopes(gitlab_cli, user):
+    cmd = [
+        "user-impersonation-token",
+        "create",
+        "--user-id",
+        user.id,
+        "--name",
+        "test-token",
+        "--scopes",
+        "api,read_user",
+        "--expires-at",
+        datetime.date.today().isoformat(),
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_list_user_projects(gitlab_cli, user):
+    cmd = ["user-project", "list", "--user-id", user.id]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
diff --git a/tests/functional/cli/test_cli_v4.py b/tests/functional/cli/test_cli_v4.py
new file mode 100644
index 000000000..189881207
--- /dev/null
+++ b/tests/functional/cli/test_cli_v4.py
@@ -0,0 +1,726 @@
+import datetime
+import os
+import time
+
+branch = "BRANCH-cli-v4"
+
+
+def test_create_project(gitlab_cli):
+    name = "test-project1"
+
+    cmd = ["project", "create", "--name", name]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+    assert name in ret.stdout
+
+
+def test_update_project(gitlab_cli, project):
+    description = "My New Description"
+
+    cmd = ["project", "update", "--id", project.id, "--description", description]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+    assert description in ret.stdout
+
+
+def test_validate_project_ci_lint(gitlab_cli, project, valid_gitlab_ci_yml):
+    cmd = [
+        "project-ci-lint",
+        "validate",
+        "--project-id",
+        project.id,
+        "--content",
+        valid_gitlab_ci_yml,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_validate_project_ci_lint_invalid_exits_non_zero(
+    gitlab_cli, project, invalid_gitlab_ci_yml
+):
+    cmd = [
+        "project-ci-lint",
+        "validate",
+        "--project-id",
+        project.id,
+        "--content",
+        invalid_gitlab_ci_yml,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert not ret.success
+    assert "CI YAML Lint failed (Invalid configuration format)" in ret.stderr
+
+
+def test_create_group(gitlab_cli):
+    name = "test-group1"
+    path = "group1"
+
+    cmd = ["group", "create", "--name", name, "--path", path]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+    assert name in ret.stdout
+    assert path in ret.stdout
+
+
+def test_update_group(gitlab_cli, gl, group):
+    description = "My New Description"
+
+    cmd = ["group", "update", "--id", group.id, "--description", description]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+    group = gl.groups.get(group.id)
+    assert group.description == description
+
+
+def test_create_user(gitlab_cli, gl):
+    email = "fake@email.com"
+    username = "user1"
+    name = "User One"
+    password = "E4596f8be406Bc3a14a4ccdb1df80587"
+
+    cmd = [
+        "user",
+        "create",
+        "--email",
+        email,
+        "--username",
+        username,
+        "--name",
+        name,
+        "--password",
+        password,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+    user = gl.users.list(username=username)[0]
+
+    assert user.email == email
+    assert user.username == username
+    assert user.name == name
+
+
+def test_get_user_by_id(gitlab_cli, user):
+    cmd = ["user", "get", "--id", user.id]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+    assert str(user.id) in ret.stdout
+
+
+def test_list_users_verbose_output(gitlab_cli):
+    cmd = ["-v", "user", "list"]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+    assert "avatar-url" in ret.stdout
+
+
+def test_cli_args_not_in_output(gitlab_cli):
+    cmd = ["-v", "user", "list"]
+    ret = gitlab_cli(cmd)
+
+    assert "config-file" not in ret.stdout
+
+
+def test_add_member_to_project(gitlab_cli, project, user):
+    access_level = "40"
+
+    cmd = [
+        "project-member",
+        "create",
+        "--project-id",
+        project.id,
+        "--user-id",
+        user.id,
+        "--access-level",
+        access_level,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_list_user_memberships(gitlab_cli, user):
+    cmd = ["user-membership", "list", "--user-id", user.id]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_create_project_issue(gitlab_cli, project):
+    title = "my issue"
+    description = "my issue description"
+
+    cmd = [
+        "project-issue",
+        "create",
+        "--project-id",
+        project.id,
+        "--title",
+        title,
+        "--description",
+        description,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+    assert title in ret.stdout
+
+
+def test_create_issue_note(gitlab_cli, issue):
+    body = "body"
+
+    cmd = [
+        "project-issue-note",
+        "create",
+        "--project-id",
+        issue.project_id,
+        "--issue-iid",
+        issue.iid,
+        "--body",
+        body,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_create_branch(gitlab_cli, project):
+    cmd = [
+        "project-branch",
+        "create",
+        "--project-id",
+        project.id,
+        "--branch",
+        branch,
+        "--ref",
+        "main",
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_create_merge_request(gitlab_cli, project):
+
+    cmd = [
+        "project-merge-request",
+        "create",
+        "--project-id",
+        project.id,
+        "--source-branch",
+        branch,
+        "--target-branch",
+        "main",
+        "--title",
+        "Update README",
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_accept_request_merge(gitlab_cli, project):
+    # MR needs at least 1 commit before we can merge
+    mr = project.mergerequests.list()[0]
+    file_data = {
+        "branch": mr.source_branch,
+        "file_path": "test-cli-v4.md",
+        "content": "Content",
+        "commit_message": "chore: test-cli-v4 change",
+    }
+    project.files.create(file_data)
+    # Pause to let GL catch up (happens on hosted too, sometimes takes a while for server to be ready to merge)
+    time.sleep(30)
+
+    approve_cmd = [
+        "project-merge-request",
+        "merge",
+        "--project-id",
+        project.id,
+        "--iid",
+        mr.iid,
+    ]
+    ret = gitlab_cli(approve_cmd)
+
+    assert ret.success
+
+
+def test_create_project_label(gitlab_cli, project):
+    name = "prjlabel1"
+    description = "prjlabel1 description"
+    color = "#112233"
+
+    cmd = [
+        "-v",
+        "project-label",
+        "create",
+        "--project-id",
+        project.id,
+        "--name",
+        name,
+        "--description",
+        description,
+        "--color",
+        color,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_list_project_labels(gitlab_cli, project):
+    cmd = ["-v", "project-label", "list", "--project-id", project.id]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_update_project_label(gitlab_cli, label):
+    new_label = "prjlabel2"
+    new_description = "prjlabel2 description"
+    new_color = "#332211"
+
+    cmd = [
+        "-v",
+        "project-label",
+        "update",
+        "--project-id",
+        label.project_id,
+        "--name",
+        label.name,
+        "--new-name",
+        new_label,
+        "--description",
+        new_description,
+        "--color",
+        new_color,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_delete_project_label(gitlab_cli, label):
+    # TODO: due to update above, we'd need a function-scope label fixture
+    label_name = "prjlabel2"
+
+    cmd = [
+        "-v",
+        "project-label",
+        "delete",
+        "--project-id",
+        label.project_id,
+        "--name",
+        label_name,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_create_group_label(gitlab_cli, group):
+    name = "grouplabel1"
+    description = "grouplabel1 description"
+    color = "#112233"
+
+    cmd = [
+        "-v",
+        "group-label",
+        "create",
+        "--group-id",
+        group.id,
+        "--name",
+        name,
+        "--description",
+        description,
+        "--color",
+        color,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_list_group_labels(gitlab_cli, group):
+    cmd = ["-v", "group-label", "list", "--group-id", group.id]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_update_group_label(gitlab_cli, group_label):
+    new_label = "grouplabel2"
+    new_description = "grouplabel2 description"
+    new_color = "#332211"
+
+    cmd = [
+        "-v",
+        "group-label",
+        "update",
+        "--group-id",
+        group_label.group_id,
+        "--name",
+        group_label.name,
+        "--new-name",
+        new_label,
+        "--description",
+        new_description,
+        "--color",
+        new_color,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_delete_group_label(gitlab_cli, group_label):
+    # TODO: due to update above, we'd need a function-scope label fixture
+    new_label = "grouplabel2"
+
+    cmd = [
+        "-v",
+        "group-label",
+        "delete",
+        "--group-id",
+        group_label.group_id,
+        "--name",
+        new_label,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_create_project_variable(gitlab_cli, project):
+    key = "junk"
+    value = "car"
+
+    cmd = [
+        "-v",
+        "project-variable",
+        "create",
+        "--project-id",
+        project.id,
+        "--key",
+        key,
+        "--value",
+        value,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_get_project_variable(gitlab_cli, variable):
+    cmd = [
+        "-v",
+        "project-variable",
+        "get",
+        "--project-id",
+        variable.project_id,
+        "--key",
+        variable.key,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_update_project_variable(gitlab_cli, variable):
+    new_value = "bus"
+
+    cmd = [
+        "-v",
+        "project-variable",
+        "update",
+        "--project-id",
+        variable.project_id,
+        "--key",
+        variable.key,
+        "--value",
+        new_value,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_list_project_variables(gitlab_cli, project):
+    cmd = ["-v", "project-variable", "list", "--project-id", project.id]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_delete_project_variable(gitlab_cli, variable):
+    cmd = [
+        "-v",
+        "project-variable",
+        "delete",
+        "--project-id",
+        variable.project_id,
+        "--key",
+        variable.key,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_delete_branch(gitlab_cli, project):
+    cmd = ["project-branch", "delete", "--project-id", project.id, "--name", branch]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_project_upload_file(gitlab_cli, project):
+    cmd = [
+        "project",
+        "upload",
+        "--id",
+        project.id,
+        "--filename",
+        __file__,
+        "--filepath",
+        os.path.realpath(__file__),
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_get_application_settings(gitlab_cli):
+    cmd = ["application-settings", "get"]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_update_application_settings(gitlab_cli):
+    cmd = ["application-settings", "update", "--signup-enabled", "false"]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_create_project_with_values_from_file(gitlab_cli, fixture_dir, tmpdir):
+    name = "gitlab-project-from-file"
+    description = "Multiline\n\nData\n"
+    from_file = tmpdir.join(name)
+    from_file.write(description)
+    from_file_path = f"@{str(from_file)}"
+    avatar_file = fixture_dir / "avatar.png"
+    assert avatar_file.exists()
+    avatar_file_path = f"@{avatar_file}"
+
+    cmd = [
+        "-v",
+        "project",
+        "create",
+        "--name",
+        name,
+        "--description",
+        from_file_path,
+        "--avatar",
+        avatar_file_path,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+    assert description in ret.stdout
+
+
+def test_create_project_with_values_at_prefixed(gitlab_cli, tmpdir):
+    name = "gitlab-project-at-prefixed"
+    description = "@at-prefixed"
+    at_prefixed = f"@{description}"
+
+    cmd = ["-v", "project", "create", "--name", name, "--description", at_prefixed]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+    assert description in ret.stdout
+
+
+def test_create_project_deploy_token(gitlab_cli, project):
+    name = "project-token"
+    username = "root"
+    expires_at = datetime.date.today().isoformat()
+    scopes = "read_registry"
+
+    cmd = [
+        "-v",
+        "project-deploy-token",
+        "create",
+        "--project-id",
+        project.id,
+        "--name",
+        name,
+        "--username",
+        username,
+        "--expires-at",
+        expires_at,
+        "--scopes",
+        scopes,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+    assert name in ret.stdout
+    assert username in ret.stdout
+    assert expires_at in ret.stdout
+    assert scopes in ret.stdout
+
+
+def test_list_all_deploy_tokens(gitlab_cli, deploy_token):
+    cmd = ["-v", "deploy-token", "list"]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+    assert deploy_token.name in ret.stdout
+    assert str(deploy_token.id) in ret.stdout
+    assert deploy_token.username in ret.stdout
+    assert deploy_token.expires_at in ret.stdout
+    assert deploy_token.scopes[0] in ret.stdout
+
+
+def test_list_project_deploy_tokens(gitlab_cli, deploy_token):
+    cmd = [
+        "-v",
+        "project-deploy-token",
+        "list",
+        "--project-id",
+        deploy_token.project_id,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+    assert deploy_token.name in ret.stdout
+    assert str(deploy_token.id) in ret.stdout
+    assert deploy_token.username in ret.stdout
+    assert deploy_token.expires_at in ret.stdout
+    assert deploy_token.scopes[0] in ret.stdout
+
+
+def test_delete_project_deploy_token(gitlab_cli, deploy_token):
+    cmd = [
+        "-v",
+        "project-deploy-token",
+        "delete",
+        "--project-id",
+        deploy_token.project_id,
+        "--id",
+        deploy_token.id,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+    # TODO assert not in list
+
+
+def test_create_group_deploy_token(gitlab_cli, group):
+    name = "group-token"
+    username = "root"
+    expires_at = datetime.date.today().isoformat()
+    scopes = "read_registry"
+
+    cmd = [
+        "-v",
+        "group-deploy-token",
+        "create",
+        "--group-id",
+        group.id,
+        "--name",
+        name,
+        "--username",
+        username,
+        "--expires-at",
+        expires_at,
+        "--scopes",
+        scopes,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+    assert name in ret.stdout
+    assert username in ret.stdout
+    assert expires_at in ret.stdout
+    assert scopes in ret.stdout
+
+
+def test_list_group_deploy_tokens(gitlab_cli, group_deploy_token):
+    cmd = [
+        "-v",
+        "group-deploy-token",
+        "list",
+        "--group-id",
+        group_deploy_token.group_id,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+    assert group_deploy_token.name in ret.stdout
+    assert str(group_deploy_token.id) in ret.stdout
+    assert group_deploy_token.username in ret.stdout
+    assert group_deploy_token.expires_at in ret.stdout
+    assert group_deploy_token.scopes[0] in ret.stdout
+
+
+def test_delete_group_deploy_token(gitlab_cli, group_deploy_token):
+    cmd = [
+        "-v",
+        "group-deploy-token",
+        "delete",
+        "--group-id",
+        group_deploy_token.group_id,
+        "--id",
+        group_deploy_token.id,
+    ]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+    # TODO assert not in list
+
+
+def test_project_member_all(gitlab_cli, project):
+    cmd = ["project-member-all", "list", "--project-id", project.id]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_group_member_all(gitlab_cli, group):
+    cmd = ["group-member-all", "list", "--group-id", group.id]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+# Deleting the project and group. Add your tests above here.
+def test_delete_project(gitlab_cli, project):
+    cmd = ["project", "delete", "--id", project.id]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_delete_group(gitlab_cli, group):
+    cmd = ["group", "delete", "--id", group.id]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+# Don't add tests below here as the group and project have been deleted
diff --git a/tests/functional/cli/test_cli_variables.py b/tests/functional/cli/test_cli_variables.py
new file mode 100644
index 000000000..8e8fbe8c8
--- /dev/null
+++ b/tests/functional/cli/test_cli_variables.py
@@ -0,0 +1,56 @@
+import copy
+
+import pytest
+import responses
+
+from gitlab.const import DEFAULT_URL
+
+
+def test_list_instance_variables(gitlab_cli, gl):
+    cmd = ["variable", "list"]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_list_group_variables(gitlab_cli, group):
+    cmd = ["group-variable", "list", "--group-id", group.id]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_list_project_variables(gitlab_cli, project):
+    cmd = ["project-variable", "list", "--project-id", project.id]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_list_project_variables_with_path(gitlab_cli, project):
+    cmd = ["project-variable", "list", "--project-id", project.path_with_namespace]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+@pytest.mark.script_launch_mode("inprocess")
+@responses.activate
+def test_list_project_variables_with_path_url_check(script_runner, resp_get_project):
+    resp_get_project_variables = copy.deepcopy(resp_get_project)
+    resp_get_project_variables.update(
+        url=f"{DEFAULT_URL}/api/v4/projects/project%2Fwith%2Fa%2Fnamespace/variables"
+    )
+    resp_get_project_variables.update(json=[])
+
+    responses.add(**resp_get_project_variables)
+    ret = script_runner.run(
+        [
+            "gitlab",
+            "project-variable",
+            "list",
+            "--project-id",
+            "project/with/a/namespace",
+        ]
+    )
+    assert ret.success
diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py
new file mode 100644
index 000000000..f4f2f6df3
--- /dev/null
+++ b/tests/functional/conftest.py
@@ -0,0 +1,660 @@
+from __future__ import annotations
+
+import dataclasses
+import datetime
+import logging
+import pathlib
+import tempfile
+import time
+import uuid
+from subprocess import check_output
+from typing import Sequence, TYPE_CHECKING
+
+import pytest
+import requests
+
+import gitlab
+import gitlab.base
+from tests.functional import helpers
+from tests.functional.fixtures.docker import *  # noqa
+
+SLEEP_TIME = 10
+
+
+@dataclasses.dataclass
+class GitlabVersion:
+    major: int
+    minor: int
+    patch: str
+    revision: str
+
+    def __post_init__(self):
+        self.major, self.minor = int(self.major), int(self.minor)
+
+
+@pytest.fixture(scope="session")
+def gitlab_version(gl) -> GitlabVersion:
+    version, revision = gl.version()
+    major, minor, patch = version.split(".")
+    return GitlabVersion(major=major, minor=minor, patch=patch, revision=revision)
+
+
+@pytest.fixture(scope="session")
+def fixture_dir(test_dir: pathlib.Path) -> pathlib.Path:
+    return test_dir / "functional" / "fixtures"
+
+
+@pytest.fixture(scope="session")
+def gitlab_service_name() -> str:
+    """The "service" name is the one defined in the `docker-compose.yml` file"""
+    return "gitlab"
+
+
+@pytest.fixture(scope="session")
+def gitlab_container_name() -> str:
+    """The "container" name is the one defined in the `docker-compose.yml` file
+    for the "gitlab" service"""
+    return "gitlab-test"
+
+
+@pytest.fixture(scope="session")
+def gitlab_docker_port(docker_services, gitlab_service_name: str) -> int:
+    port: int = docker_services.port_for(gitlab_service_name, container_port=80)
+    return port
+
+
+@pytest.fixture(scope="session")
+def gitlab_url(docker_ip: str, gitlab_docker_port: int) -> str:
+    return f"http://{docker_ip}:{gitlab_docker_port}"
+
+
+def reset_gitlab(gl: gitlab.Gitlab) -> None:
+    """Delete resources (such as projects, groups, users) that shouldn't
+    exist."""
+    if helpers.get_gitlab_plan(gl):
+        logging.info("GitLab EE detected")
+        # NOTE(jlvillal, timknight): By default in GitLab EE it will wait 7 days before
+        # deleting a group or project.
+        # In GL 16.0 we need to call delete with `permanently_remove=True` for projects and sub groups
+        # (handled in helpers.py safe_delete)
+        settings = gl.settings.get()
+        modified_settings = False
+        if settings.deletion_adjourned_period != 1:
+            logging.info("Setting `deletion_adjourned_period` to 1 Day")
+            settings.deletion_adjourned_period = 1
+            modified_settings = True
+        if modified_settings:
+            settings.save()
+
+    for project in gl.projects.list():
+        for project_deploy_token in project.deploytokens.list():
+            logging.info(
+                f"Deleting deploy token: {project_deploy_token.username!r} in "
+                f"project: {project.path_with_namespace!r}"
+            )
+            helpers.safe_delete(project_deploy_token)
+        logging.info(f"Deleting project: {project.path_with_namespace!r}")
+        helpers.safe_delete(project)
+
+    for group in gl.groups.list():
+        # skip deletion of a descendant group to prevent scenarios where parent group
+        # gets deleted leaving a dangling descendant whose deletion will throw 404s.
+        if group.parent_id:
+            logging.info(
+                f"Skipping deletion of {group.full_path} as it is a descendant "
+                f"group and will be removed when the parent group is deleted"
+            )
+            continue
+
+        for group_deploy_token in group.deploytokens.list():
+            logging.info(
+                f"Deleting deploy token: {group_deploy_token.username!r} in "
+                f"group: {group.path_with_namespace!r}"
+            )
+            helpers.safe_delete(group_deploy_token)
+        logging.info(f"Deleting group: {group.full_path!r}")
+        helpers.safe_delete(group)
+    for topic in gl.topics.list():
+        logging.info(f"Deleting topic: {topic.name!r}")
+        helpers.safe_delete(topic)
+    for variable in gl.variables.list():
+        logging.info(f"Deleting variable: {variable.key!r}")
+        helpers.safe_delete(variable)
+    for user in gl.users.list():
+        if user.username not in ["root", "ghost"]:
+            logging.info(f"Deleting user: {user.username!r}")
+            helpers.safe_delete(user)
+
+
+def set_token(container: str, fixture_dir: pathlib.Path) -> str:
+    logging.info("Creating API token.")
+    set_token_rb = fixture_dir / "set_token.rb"
+
+    with open(set_token_rb, encoding="utf-8") as f:
+        set_token_command = f.read().strip()
+
+    rails_command = [
+        "docker",
+        "exec",
+        container,
+        "gitlab-rails",
+        "runner",
+        set_token_command,
+    ]
+    output = check_output(rails_command).decode().strip()
+    logging.info("Finished creating API token.")
+
+    return output
+
+
+def pytest_report_collectionfinish(
+    config: pytest.Config, start_path: pathlib.Path, items: Sequence[pytest.Item]
+):
+    return [
+        "",
+        "Starting GitLab container.",
+        "Waiting for GitLab to reconfigure.",
+        "This will take a few minutes.",
+    ]
+
+
+def pytest_addoption(parser):
+    parser.addoption(
+        "--keep-containers",
+        action="store_true",
+        help="Keep containers running after testing",
+    )
+
+
+@pytest.fixture(scope="session")
+def temp_dir() -> pathlib.Path:
+    return pathlib.Path(tempfile.gettempdir())
+
+
+@pytest.fixture(scope="session")
+def check_is_alive():
+    """
+    Return a healthcheck function fixture for the GitLab container spinup.
+    """
+
+    def _check(*, container: str, start_time: float, gitlab_url: str) -> bool:
+        setup_time = time.perf_counter() - start_time
+        minutes, seconds = int(setup_time / 60), int(setup_time % 60)
+        logging.info(
+            f"Checking if GitLab container is up. "
+            f"Have been checking for {minutes} minute(s), {seconds} seconds ..."
+        )
+        logs = ["docker", "logs", container]
+        if "gitlab Reconfigured!" not in check_output(logs).decode():
+            return False
+        logging.debug("GitLab has finished reconfiguring.")
+        for check in ("health", "readiness", "liveness"):
+            url = f"{gitlab_url}/-/{check}"
+            logging.debug(f"Checking {check!r} endpoint at: {url}")
+            try:
+                result = requests.get(url, timeout=1.0)
+            except requests.exceptions.Timeout:
+                logging.info(f"{check!r} check timed out")
+                return False
+            if result.status_code != 200:
+                logging.info(f"{check!r} check did not return 200: {result!r}")
+                return False
+            logging.debug(f"{check!r} check passed: {result!r}")
+        logging.debug(f"Sleeping for {SLEEP_TIME}")
+        time.sleep(SLEEP_TIME)
+        return True
+
+    return _check
+
+
+@pytest.fixture(scope="session")
+def gitlab_token(
+    check_is_alive,
+    gitlab_container_name: str,
+    gitlab_url: str,
+    docker_services,
+    fixture_dir: pathlib.Path,
+) -> str:
+    start_time = time.perf_counter()
+    logging.info("Waiting for GitLab container to become ready.")
+    docker_services.wait_until_responsive(
+        timeout=300,
+        pause=10,
+        check=lambda: check_is_alive(
+            container=gitlab_container_name,
+            start_time=start_time,
+            gitlab_url=gitlab_url,
+        ),
+    )
+    setup_time = time.perf_counter() - start_time
+    minutes, seconds = int(setup_time / 60), int(setup_time % 60)
+    logging.info(
+        f"GitLab container is now ready after {minutes} minute(s), {seconds} seconds"
+    )
+
+    return set_token(gitlab_container_name, fixture_dir=fixture_dir)
+
+
+@pytest.fixture(scope="session")
+def gitlab_config(gitlab_url: str, gitlab_token: str, temp_dir: pathlib.Path):
+    config_file = temp_dir / "python-gitlab.cfg"
+
+    config = f"""[global]
+default = local
+timeout = 60
+
+[local]
+url = {gitlab_url}
+private_token = {gitlab_token}
+api_version = 4"""
+
+    with open(config_file, "w", encoding="utf-8") as f:
+        f.write(config)
+
+    return config_file
+
+
+@pytest.fixture(scope="session")
+def gl(gitlab_url: str, gitlab_token: str) -> gitlab.Gitlab:
+    """Helper instance to make fixtures and asserts directly via the API."""
+
+    logging.info("Instantiating python-gitlab gitlab.Gitlab instance")
+    instance = gitlab.Gitlab(gitlab_url, private_token=gitlab_token)
+    instance.auth()
+
+    logging.info("Reset GitLab")
+    reset_gitlab(instance)
+
+    return instance
+
+
+@pytest.fixture(scope="session")
+def gitlab_plan(gl: gitlab.Gitlab) -> str | None:
+    return helpers.get_gitlab_plan(gl)
+
+
+@pytest.fixture(autouse=True)
+def gitlab_premium(gitlab_plan, request) -> None:
+    if gitlab_plan in ("premium", "ultimate"):
+        return
+
+    if request.node.get_closest_marker("gitlab_ultimate"):
+        pytest.skip("Test requires GitLab Premium plan")
+
+
+@pytest.fixture(autouse=True)
+def gitlab_ultimate(gitlab_plan, request) -> None:
+    if gitlab_plan == "ultimate":
+        return
+
+    if request.node.get_closest_marker("gitlab_ultimate"):
+        pytest.skip("Test requires GitLab Ultimate plan")
+
+
+@pytest.fixture(scope="session")
+def gitlab_runner(gl: gitlab.Gitlab):
+    container = "gitlab-runner-test"
+    runner_description = "python-gitlab-runner"
+    if TYPE_CHECKING:
+        assert gl.user is not None
+
+    runner = gl.user.runners.create(
+        {"runner_type": "instance_type", "run_untagged": True}
+    )
+    url = "http://gitlab"
+
+    docker_exec = ["docker", "exec", container, "gitlab-runner"]
+    register = [
+        "register",
+        "--non-interactive",
+        "--token",
+        runner.token,
+        "--description",
+        runner_description,
+        "--url",
+        url,
+        "--clone-url",
+        url,
+        "--executor",
+        "shell",
+    ]
+
+    yield check_output(docker_exec + register).decode()
+
+    gl.runners.delete(token=runner.token)
+
+
+@pytest.fixture(scope="module")
+def group(gl):
+    """Group fixture for group API resource tests."""
+    _id = uuid.uuid4().hex
+    data = {"name": f"test-group-{_id}", "path": f"group-{_id}"}
+    group = gl.groups.create(data)
+
+    yield group
+
+    helpers.safe_delete(group)
+
+
+@pytest.fixture(scope="module")
+def project(gl):
+    """Project fixture for project API resource tests."""
+    _id = uuid.uuid4().hex
+    name = f"test-project-{_id}"
+
+    project = gl.projects.create(name=name)
+
+    yield project
+
+    helpers.safe_delete(project)
+
+
+@pytest.fixture(scope="function")
+def make_merge_request(project):
+    """Fixture factory used to create a merge_request.
+
+    It will create a branch, add a commit to the branch, and then create a
+    merge request against project.default_branch. The MR will be returned.
+
+    When finished any created merge requests and branches will be deleted.
+
+    NOTE: No attempt is made to restore project.default_branch to its previous
+    state. So if the merge request is merged then its content will be in the
+    project.default_branch branch.
+    """
+
+    to_delete = []
+
+    def _make_merge_request(*, source_branch: str, create_pipeline: bool = False):
+        # Wait for processes to be done before we start...
+        # NOTE(jlvillal): Sometimes the CI would give a "500 Internal Server
+        # Error". Hoping that waiting until all other processes are done will
+        # help with that.
+        # Pause to let GL catch up (happens on hosted too, sometimes takes a while for server to be ready to merge)
+        time.sleep(30)
+
+        project.refresh()  # Gets us the current default branch
+        logging.info(f"Creating branch {source_branch}")
+        mr_branch = project.branches.create(
+            {"branch": source_branch, "ref": project.default_branch}
+        )
+        # NOTE(jlvillal): Must create a commit in the new branch before we can
+        # create an MR that will work.
+        project.files.create(
+            {
+                "file_path": f"README.{source_branch}",
+                "branch": source_branch,
+                "content": "Initial content",
+                "commit_message": "New commit in new branch",
+            }
+        )
+
+        if create_pipeline:
+            project.files.create(
+                {
+                    "file_path": ".gitlab-ci.yml",
+                    "branch": source_branch,
+                    "content": """
+test:
+  rules:
+    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
+  script:
+    - sleep 24h  # We don't expect this to finish
+""",
+                    "commit_message": "Add a simple pipeline",
+                }
+            )
+        mr = project.mergerequests.create(
+            {
+                "source_branch": source_branch,
+                "target_branch": project.default_branch,
+                "title": "Should remove source branch",
+                "remove_source_branch": True,
+            }
+        )
+
+        # Pause to let GL catch up (happens on hosted too, sometimes takes a while for server to be ready to merge)
+        time.sleep(5)
+
+        mr_iid = mr.iid
+        for _ in range(60):
+            mr = project.mergerequests.get(mr_iid)
+            if (
+                mr.detailed_merge_status == "checking"
+                or mr.detailed_merge_status == "unchecked"
+            ):
+                time.sleep(0.5)
+            else:
+                break
+
+        assert mr.detailed_merge_status != "checking"
+        assert mr.detailed_merge_status != "unchecked"
+
+        to_delete.extend([mr, mr_branch])
+        return mr
+
+    yield _make_merge_request
+
+    for object in to_delete:
+        helpers.safe_delete(object)
+
+
+@pytest.fixture(scope="function")
+def merge_request(make_merge_request, project):
+    _id = uuid.uuid4().hex
+    return make_merge_request(source_branch=f"branch-{_id}")
+
+
+@pytest.fixture(scope="function")
+def merge_request_with_pipeline(make_merge_request, project):
+    _id = uuid.uuid4().hex
+    return make_merge_request(source_branch=f"branch-{_id}", create_pipeline=True)
+
+
+@pytest.fixture(scope="module")
+def project_file(project):
+    """File fixture for tests requiring a project with files and branches."""
+    project_file = project.files.create(
+        {
+            "file_path": "README",
+            "branch": "main",
+            "content": "Initial content",
+            "commit_message": "Initial commit",
+        }
+    )
+
+    return project_file
+
+
+@pytest.fixture(scope="function")
+def release(project, project_file):
+    _id = uuid.uuid4().hex
+    name = f"we_have_a_slash/test-release-{_id}"
+
+    project.refresh()  # Gets us the current default branch
+    release = project.releases.create(
+        {
+            "name": name,
+            "tag_name": _id,
+            "description": "description",
+            "ref": project.default_branch,
+        }
+    )
+
+    return release
+
+
+@pytest.fixture(scope="function")
+def service(project):
+    """This is just a convenience fixture to make test cases slightly prettier. Project
+    services are not idempotent. A service cannot be retrieved until it is enabled.
+    After it is enabled the first time, it can never be fully deleted, only disabled."""
+    service = project.services.update("asana", {"api_key": "api_key"})
+
+    yield service
+
+    try:
+        project.services.delete("asana")
+    except gitlab.exceptions.GitlabDeleteError as e:
+        print(f"Service already disabled: {e}")
+
+
+@pytest.fixture(scope="module")
+def user(gl):
+    """User fixture for user API resource tests."""
+    _id = uuid.uuid4().hex
+    email = f"user{_id}@email.com"
+    username = f"user{_id}"
+    name = f"User {_id}"
+    password = "E4596f8be406Bc3a14a4ccdb1df80587"
+
+    user = gl.users.create(email=email, username=username, name=name, password=password)
+
+    yield user
+
+    helpers.safe_delete(user)
+
+
+@pytest.fixture(scope="module")
+def issue(project):
+    """Issue fixture for issue API resource tests."""
+    _id = uuid.uuid4().hex
+    data = {"title": f"Issue {_id}", "description": f"Issue {_id} description"}
+
+    return project.issues.create(data)
+
+
+@pytest.fixture(scope="module")
+def milestone(project):
+    _id = uuid.uuid4().hex
+    data = {"title": f"milestone{_id}"}
+
+    return project.milestones.create(data)
+
+
+@pytest.fixture(scope="module")
+def label(project):
+    """Label fixture for project label API resource tests."""
+    _id = uuid.uuid4().hex
+    data = {
+        "name": f"prjlabel{_id}",
+        "description": f"prjlabel1 {_id} description",
+        "color": "#112233",
+    }
+
+    return project.labels.create(data)
+
+
+@pytest.fixture(scope="module")
+def group_label(group):
+    """Label fixture for group label API resource tests."""
+    _id = uuid.uuid4().hex
+    data = {
+        "name": f"grplabel{_id}",
+        "description": f"grplabel1 {_id} description",
+        "color": "#112233",
+    }
+
+    return group.labels.create(data)
+
+
+@pytest.fixture(scope="module")
+def epic(group):
+    """Fixture for group epic API resource tests."""
+    _id = uuid.uuid4().hex
+    return group.epics.create({"title": f"epic-{_id}", "description": f"Epic {_id}"})
+
+
+@pytest.fixture(scope="module")
+def variable(project):
+    """Variable fixture for project variable API resource tests."""
+    _id = uuid.uuid4().hex
+    data = {"key": f"var{_id}", "value": f"Variable {_id}"}
+
+    return project.variables.create(data)
+
+
+@pytest.fixture(scope="module")
+def deploy_token(project):
+    """Deploy token fixture for project deploy token API resource tests."""
+    _id = uuid.uuid4().hex
+    data = {
+        "name": f"token-{_id}",
+        "username": "root",
+        "expires_at": datetime.date.today().isoformat(),
+        "scopes": "read_registry",
+    }
+
+    return project.deploytokens.create(data)
+
+
+@pytest.fixture(scope="module")
+def group_deploy_token(group):
+    """Deploy token fixture for group deploy token API resource tests."""
+    _id = uuid.uuid4().hex
+    data = {
+        "name": f"group-token-{_id}",
+        "username": "root",
+        "expires_at": datetime.date.today().isoformat(),
+        "scopes": "read_registry",
+    }
+
+    return group.deploytokens.create(data)
+
+
+@pytest.fixture(scope="session")
+def GPG_KEY():
+    return """-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mQENBFn5mzYBCADH6SDVPAp1zh/hxmTi0QplkOfExBACpuY6OhzNdIg+8/528b3g
+Y5YFR6T/HLv/PmeHskUj21end1C0PNG2T9dTx+2Vlh9ISsSG1kyF9T5fvMR3bE0x
+Dl6S489CXZrjPTS9SHk1kF+7dwjUxLJyxF9hPiSihFefDFu3NeOtG/u8vbC1mewQ
+ZyAYue+mqtqcCIFFoBz7wHKMWjIVSJSyTkXExu4OzpVvy3l2EikbvavI3qNz84b+
+Mgkv/kiBlNoCy3CVuPk99RYKZ3lX1vVtqQ0OgNGQvb4DjcpyjmbKyibuZwhDjIOh
+au6d1OyEbayTntd+dQ4j9EMSnEvm/0MJ4eXPABEBAAG0G0dpdGxhYlRlc3QxIDxm
+YWtlQGZha2UudGxkPokBNwQTAQgAIQUCWfmbNgIbAwULCQgHAgYVCAkKCwIEFgID
+AQIeAQIXgAAKCRBgxELHf8f3hF3yB/wNJlWPKY65UsB4Lo0hs1OxdxCDqXogSi0u
+6crDEIiyOte62pNZKzWy8TJcGZvznRTZ7t8hXgKFLz3PRMcl+vAiRC6quIDUj+2V
+eYfwaItd1lUfzvdCaC7Venf4TQ74f5vvNg/zoGwE6eRoSbjlLv9nqsxeA0rUBUQL
+LYikWhVMP3TrlfgfduYvh6mfgh57BDLJ9kJVpyfxxx9YLKZbaas9sPa6LgBtR555
+JziUxHmbEv8XCsUU8uoFeP1pImbNBplqE3wzJwzOMSmmch7iZzrAwfN7N2j3Wj0H
+B5kQddJ9dmB4BbU0IXGhWczvdpxboI2wdY8a1JypxOdePoph/43iuQENBFn5mzYB
+CADnTPY0Zf3d9zLjBNgIb3yDl94uOcKCq0twNmyjMhHzGqw+UMe9BScy34GL94Al
+xFRQoaL+7P8hGsnsNku29A/VDZivcI+uxTx4WQ7OLcn7V0bnHV4d76iky2ufbUt/
+GofthjDs1SonePO2N09sS4V4uK0d5N4BfCzzXgvg8etCLxNmC9BGt7AaKUUzKBO4
+2QvNNaC2C/8XEnOgNWYvR36ylAXAmo0sGFXUsBCTiq1fugS9pwtaS2JmaVpZZ3YT
+pMZlS0+SjC5BZYFqSmKCsA58oBRzCxQz57nR4h5VEflgD+Hy0HdW0UHETwz83E6/
+U0LL6YyvhwFr6KPq5GxinSvfABEBAAGJAR8EGAEIAAkFAln5mzYCGwwACgkQYMRC
+x3/H94SJgwgAlKQb10/xcL/epdDkR7vbiei7huGLBpRDb/L5fM8B5W77Qi8Xmuqj
+cCu1j99ZCA5hs/vwVn8j8iLSBGMC5gxcuaar/wtmiaEvT9fO/h6q4opG7NcuiJ8H
+wRj8ccJmRssNqDD913PLz7T40Ts62blhrEAlJozGVG/q7T3RAZcskOUHKeHfc2RI
+YzGsC/I9d7k6uxAv1L9Nm5F2HaAQDzhkdd16nKkGaPGR35cT1JLInkfl5cdm7ldN
+nxs4TLO3kZjUTgWKdhpgRNF5hwaz51ZjpebaRf/ZqRuNyX4lIRolDxzOn/+O1o8L
+qG2ZdhHHmSK2LaQLFiSprUkikStNU9BqSQ==
+=5OGa
+-----END PGP PUBLIC KEY BLOCK-----"""
+
+
+@pytest.fixture(scope="session")
+def SSH_KEY():
+    return (
+        "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDZAjAX8vTiHD7Yi3/EzuVaDChtih"
+        "79HyJZ6H9dEqxFfmGA1YnncE0xujQ64TCebhkYJKzmTJCImSVkOu9C4hZgsw6eE76n"
+        "+Cg3VwEeDUFy+GXlEJWlHaEyc3HWioxgOALbUp3rOezNh+d8BDwwqvENGoePEBsz5l"
+        "a6WP5lTi/HJIjAl6Hu+zHgdj1XVExeH+S52EwpZf/ylTJub0Bl5gHwf/siVE48mLMI"
+        "sqrukXTZ6Zg+8EHAIvIQwJ1dKcXe8P5IoLT7VKrbkgAnolS0I8J+uH7KtErZJb5oZh"
+        "S4OEwsNpaXMAr+6/wWSpircV2/e7sFLlhlKBC4Iq1MpqlZ7G3p foo@bar"
+    )
+
+
+@pytest.fixture(scope="session")
+def DEPLOY_KEY():
+    return (
+        "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDFdRyjJQh+1niBpXqE2I8dzjG"
+        "MXFHlRjX9yk/UfOn075IdaockdU58sw2Ai1XIWFpZpfJkW7z+P47ZNSqm1gzeXI"
+        "rtKa9ZUp8A7SZe8vH4XVn7kh7bwWCUirqtn8El9XdqfkzOs/+FuViriUWoJVpA6"
+        "WZsDNaqINFKIA5fj/q8XQw+BcS92L09QJg9oVUuH0VVwNYbU2M2IRmSpybgC/gu"
+        "uWTrnCDMmLItksATifLvRZwgdI8dr+q6tbxbZknNcgEPrI2jT0hYN9ZcjNeWuyv"
+        "rke9IepE7SPBT41C+YtUX4dfDZDmczM1cE0YL/krdUCfuZHMa4ZS2YyNd6slufc"
+        "vn bar@foo"
+    )
diff --git a/tests/functional/ee-test.py b/tests/functional/ee-test.py
new file mode 100755
index 000000000..e69de29bb
diff --git a/tests/functional/fixtures/.env b/tests/functional/fixtures/.env
new file mode 100644
index 000000000..e85f85e6f
--- /dev/null
+++ b/tests/functional/fixtures/.env
@@ -0,0 +1,4 @@
+GITLAB_IMAGE=gitlab/gitlab-ee
+GITLAB_TAG=17.8.2-ee.0
+GITLAB_RUNNER_IMAGE=gitlab/gitlab-runner
+GITLAB_RUNNER_TAG=96856197
diff --git a/tests/functional/fixtures/__init__.py b/tests/functional/fixtures/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/functional/fixtures/avatar.png b/tests/functional/fixtures/avatar.png
new file mode 100644
index 000000000..a3a767cd4
Binary files /dev/null and b/tests/functional/fixtures/avatar.png differ
diff --git a/tests/functional/fixtures/create_license.rb b/tests/functional/fixtures/create_license.rb
new file mode 100644
index 000000000..04ddb4533
--- /dev/null
+++ b/tests/functional/fixtures/create_license.rb
@@ -0,0 +1,51 @@
+# NOTE: As of 2022-06-01 the GitLab Enterprise Edition License has the following
+# section:
+#   Notwithstanding the foregoing, you may copy and modify the Software for development
+#   and testing purposes, without requiring a subscription.
+#
+# https://gitlab.com/gitlab-org/gitlab/-/blob/29503bc97b96af8d4876dc23fc8996e3dab7d211/ee/LICENSE
+#
+# This code is strictly intended for use in the testing framework of python-gitlab
+
+# Code inspired by MIT licensed code at: https://github.com/CONIGUERO/gitlab-license.git
+
+require 'openssl'
+require 'gitlab/license'
+
+# Generate a 2048 bit key pair.
+license_encryption_key = OpenSSL::PKey::RSA.generate(2048)
+
+# Save the private key
+File.open("/.license_encryption_key", "w") { |f| f.write(license_encryption_key.to_pem) }
+# Save the public key
+public_key = license_encryption_key.public_key
+File.open("/.license_encryption_key.pub", "w") { |f| f.write(public_key.to_pem) }
+File.open("/opt/gitlab/embedded/service/gitlab-rails/.license_encryption_key.pub", "w") { |f| f.write(public_key.to_pem) }
+
+Gitlab::License.encryption_key = license_encryption_key
+
+# Build a new license.
+license = Gitlab::License.new
+
+license.licensee = {
+  "Name"    => "python-gitlab-ci",
+  "Company" => "python-gitlab-ci",
+  "Email"   => "python-gitlab-ci@example.com",
+}
+
+# The date the license starts. 
+license.starts_at         = Date.today
+# Want to make sure we get at least 1 day of usage. Do two days after because if CI
+# started at 23:59 we could be expired in one minute if we only did one next_day.
+license.expires_at         = Date.today.next_day.next_day
+
+# Use 'ultimate' plan so that we can test all features in the CI
+license.restrictions = {
+  :plan => "ultimate", 
+  :id   => rand(1000..99999999)
+}
+
+# Export the license, which encrypts and encodes it.
+data = license.export
+
+File.open("/python-gitlab-ci.gitlab-license", 'w') { |file| file.write(data) }
diff --git a/tests/functional/fixtures/docker-compose.yml b/tests/functional/fixtures/docker-compose.yml
new file mode 100644
index 000000000..f36f3d2fd
--- /dev/null
+++ b/tests/functional/fixtures/docker-compose.yml
@@ -0,0 +1,52 @@
+version: '3.5'
+
+networks:
+  gitlab-network:
+    name: gitlab-network
+
+services:
+  gitlab:
+    image: '${GITLAB_IMAGE}:${GITLAB_TAG}'
+    container_name: 'gitlab-test'
+    hostname: 'gitlab.test'
+    privileged: true # Just in case https://gitlab.com/gitlab-org/omnibus-gitlab/-/issues/1350
+    environment:
+      GITLAB_ROOT_PASSWORD: 5iveL!fe
+      GITLAB_OMNIBUS_CONFIG: |
+        external_url 'http://127.0.0.1:8080'
+        registry['enable'] = false
+        nginx['redirect_http_to_https'] = false
+        nginx['listen_port'] = 80
+        nginx['listen_https'] = false
+        pages_external_url 'http://pages.gitlab.lxd'
+        gitlab_pages['enable'] = true
+        gitlab_pages['inplace_chroot'] = true
+        prometheus['enable'] = false
+        alertmanager['enable'] = false
+        node_exporter['enable'] = false
+        redis_exporter['enable'] = false
+        postgres_exporter['enable'] = false
+        pgbouncer_exporter['enable'] = false
+        gitlab_exporter['enable'] = false
+        letsencrypt['enable'] = false
+        gitlab_rails['initial_license_file'] = '/python-gitlab-ci.gitlab-license'
+        gitlab_rails['monitoring_whitelist'] = ['0.0.0.0/0']
+    entrypoint:
+      - /bin/sh
+      - -c
+      - ruby /create_license.rb && /assets/wrapper
+    volumes:
+      - ${PWD}/tests/functional/fixtures/create_license.rb:/create_license.rb
+    ports:
+      - '8080:80'
+      - '2222:22'
+    networks:
+      - gitlab-network
+
+  gitlab-runner:
+    image: '${GITLAB_RUNNER_IMAGE}:${GITLAB_RUNNER_TAG}'
+    container_name: 'gitlab-runner-test'
+    depends_on:
+      - gitlab
+    networks:
+      - gitlab-network
diff --git a/tests/functional/fixtures/docker.py b/tests/functional/fixtures/docker.py
new file mode 100644
index 000000000..26bc440b5
--- /dev/null
+++ b/tests/functional/fixtures/docker.py
@@ -0,0 +1,26 @@
+"""
+pytest-docker fixture overrides.
+See https://github.com/avast/pytest-docker#available-fixtures.
+"""
+
+import pytest
+
+
+@pytest.fixture(scope="session")
+def docker_compose_project_name():
+    """Set a consistent project name to enable optional reuse of containers."""
+    return "pytest-python-gitlab"
+
+
+@pytest.fixture(scope="session")
+def docker_compose_file(fixture_dir):
+    return fixture_dir / "docker-compose.yml"
+
+
+@pytest.fixture(scope="session")
+def docker_cleanup(request):
+    """Conditionally keep containers around by overriding the cleanup command."""
+    if request.config.getoption("--keep-containers"):
+        # Print version and exit.
+        return "-v"
+    return "down -v"
diff --git a/tests/functional/fixtures/invalid_auth.cfg b/tests/functional/fixtures/invalid_auth.cfg
new file mode 100644
index 000000000..3d61d67e5
--- /dev/null
+++ b/tests/functional/fixtures/invalid_auth.cfg
@@ -0,0 +1,3 @@
+[test]
+url = https://gitlab.com
+private_token = abc123
diff --git a/tests/functional/fixtures/invalid_version.cfg b/tests/functional/fixtures/invalid_version.cfg
new file mode 100644
index 000000000..31059a277
--- /dev/null
+++ b/tests/functional/fixtures/invalid_version.cfg
@@ -0,0 +1,3 @@
+[test]
+api_version = 3
+url = https://gitlab.example.com
diff --git a/tests/functional/fixtures/set_token.rb b/tests/functional/fixtures/set_token.rb
new file mode 100644
index 000000000..eec4e03ec
--- /dev/null
+++ b/tests/functional/fixtures/set_token.rb
@@ -0,0 +1,9 @@
+# https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#programmatically-creating-a-personal-access-token
+
+user = User.find_by_username('root')
+
+token = user.personal_access_tokens.first_or_create(scopes: ['api', 'sudo'], name: 'default', expires_at: 365.days.from_now);
+token.set_token('glpat-python-gitlab-token_');
+token.save!
+
+puts token.token
diff --git a/tests/functional/helpers.py b/tests/functional/helpers.py
new file mode 100644
index 000000000..090673bf7
--- /dev/null
+++ b/tests/functional/helpers.py
@@ -0,0 +1,73 @@
+from __future__ import annotations
+
+import logging
+import time
+from typing import TYPE_CHECKING
+
+import pytest
+
+import gitlab
+import gitlab.base
+import gitlab.exceptions
+
+SLEEP_INTERVAL = 0.5
+TIMEOUT = 60  # seconds before timeout will occur
+MAX_ITERATIONS = int(TIMEOUT / SLEEP_INTERVAL)
+
+
+def get_gitlab_plan(gl: gitlab.Gitlab) -> str | None:
+    """Determine the license available on the GitLab instance"""
+    try:
+        license = gl.get_license()
+    except gitlab.exceptions.GitlabLicenseError:
+        # Without a license we assume only Free features are available
+        return None
+
+    if TYPE_CHECKING:
+        assert isinstance(license["plan"], str)
+    return license["plan"]
+
+
+def safe_delete(object: gitlab.base.RESTObject) -> None:
+    """Ensure the object specified can not be retrieved. If object still exists after
+    timeout period, fail the test"""
+    manager = object.manager
+    for index in range(MAX_ITERATIONS):
+        try:
+            object = manager.get(object.get_id())  # type: ignore[attr-defined]
+        except gitlab.exceptions.GitlabGetError:
+            return
+
+        if index:
+            logging.info(f"Attempt {index + 1} to delete {object!r}.")
+        try:
+            if isinstance(object, gitlab.v4.objects.User):
+                # You can't use this option if the selected user is the sole owner of any groups
+                # Use `hard_delete=True` or a 'Ghost User' may be created.
+                # https://docs.gitlab.com/ee/api/users.html#user-deletion
+                object.delete(hard_delete=True)
+                if index > 1:
+                    # If User is the sole owner of any group it won't be deleted,
+                    # which combined with parents group never immediately deleting in GL 16
+                    # we shouldn't cause test to fail if it still exists
+                    return
+            elif isinstance(object, gitlab.v4.objects.Project):
+                # Immediately delete rather than waiting for at least 1day
+                # https://docs.gitlab.com/ee/api/projects.html#delete-project
+                object.delete(permanently_remove=True)
+                pass
+            else:
+                # We only attempt to delete parent groups to prevent dangling sub-groups
+                # However parent groups can only be deleted on a delay in Gl 16
+                # https://docs.gitlab.com/ee/api/groups.html#remove-group
+                object.delete()
+        except gitlab.exceptions.GitlabDeleteError:
+            logging.info(f"{object!r} already deleted or scheduled for deletion.")
+            if isinstance(object, gitlab.v4.objects.Group):
+                # Parent groups can never be immediately deleted in GL 16,
+                # so don't cause test to fail if it still exists
+                return
+            pass
+
+        time.sleep(SLEEP_INTERVAL)
+    pytest.fail(f"{object!r} was not deleted")
diff --git a/tests/install/test_install.py b/tests/install/test_install.py
new file mode 100644
index 000000000..e262bb444
--- /dev/null
+++ b/tests/install/test_install.py
@@ -0,0 +1,6 @@
+import pytest
+
+
+def test_install() -> None:
+    with pytest.raises(ImportError):
+        import aiohttp  # type: ignore # noqa
diff --git a/tests/smoke/__init__.py b/tests/smoke/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/smoke/test_dists.py b/tests/smoke/test_dists.py
new file mode 100644
index 000000000..338ed70b7
--- /dev/null
+++ b/tests/smoke/test_dists.py
@@ -0,0 +1,47 @@
+import subprocess
+import sys
+import tarfile
+import zipfile
+from pathlib import Path
+
+import pytest
+
+from gitlab._version import __title__, __version__
+
+DOCS_DIR = "docs"
+TEST_DIR = "tests"
+DIST_NORMALIZED_TITLE = f"{__title__.replace('-', '_')}-{__version__}"
+SDIST_FILE = f"{DIST_NORMALIZED_TITLE}.tar.gz"
+WHEEL_FILE = f"{DIST_NORMALIZED_TITLE}-py{sys.version_info.major}-none-any.whl"
+PY_TYPED = "gitlab/py.typed"
+
+
+@pytest.fixture(scope="session")
+def build(tmp_path_factory: pytest.TempPathFactory):
+    temp_dir = tmp_path_factory.mktemp("build")
+    subprocess.run([sys.executable, "-m", "build", "--outdir", temp_dir], check=True)
+    return temp_dir
+
+
+def test_sdist_includes_correct_files(build: Path) -> None:
+    sdist = tarfile.open(build / SDIST_FILE, "r:gz")
+
+    docs_dir = sdist.getmember(f"{DIST_NORMALIZED_TITLE}/{DOCS_DIR}")
+    test_dir = sdist.getmember(f"{DIST_NORMALIZED_TITLE}/{TEST_DIR}")
+    readme = sdist.getmember(f"{DIST_NORMALIZED_TITLE}/README.rst")
+    py_typed = sdist.getmember(f"{DIST_NORMALIZED_TITLE}/{PY_TYPED}")
+
+    assert docs_dir.isdir()
+    assert test_dir.isdir()
+    assert py_typed.isfile()
+    assert readme.isfile()
+
+
+def test_wheel_includes_correct_files(build: Path) -> None:
+    wheel = zipfile.ZipFile(build / WHEEL_FILE)
+    assert PY_TYPED in wheel.namelist()
+
+
+def test_wheel_excludes_docs_and_tests(build: Path) -> None:
+    wheel = zipfile.ZipFile(build / WHEEL_FILE)
+    assert not any(file.startswith((DOCS_DIR, TEST_DIR)) for file in wheel.namelist())
diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/_backends/__init__.py b/tests/unit/_backends/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/_backends/test_requests_backend.py b/tests/unit/_backends/test_requests_backend.py
new file mode 100644
index 000000000..2dd36f80d
--- /dev/null
+++ b/tests/unit/_backends/test_requests_backend.py
@@ -0,0 +1,51 @@
+import pytest
+from requests_toolbelt.multipart.encoder import MultipartEncoder  # type: ignore
+
+from gitlab._backends import requests_backend
+
+
+class TestSendData:
+    def test_senddata_json(self) -> None:
+        result = requests_backend.SendData(
+            json={"a": 1}, content_type="application/json"
+        )
+        assert result.data is None
+
+    def test_senddata_data(self) -> None:
+        result = requests_backend.SendData(
+            data={"b": 2}, content_type="application/octet-stream"
+        )
+        assert result.json is None
+
+    def test_senddata_json_and_data(self) -> None:
+        with pytest.raises(ValueError, match=r"json={'a': 1}  data={'b': 2}"):
+            requests_backend.SendData(
+                json={"a": 1}, data={"b": 2}, content_type="application/json"
+            )
+
+
+class TestRequestsBackend:
+    @pytest.mark.parametrize(
+        "test_data,expected",
+        [
+            (False, "0"),
+            (True, "1"),
+            ("12", "12"),
+            (12, "12"),
+            (12.0, "12.0"),
+            (complex(-2, 7), "(-2+7j)"),
+        ],
+    )
+    def test_prepare_send_data_non_strings(self, test_data, expected) -> None:
+        assert isinstance(expected, str)
+        files = {"file": ("file.tar.gz", "12345", "application/octet-stream")}
+        post_data = {"test_data": test_data}
+
+        result = requests_backend.RequestsBackend.prepare_send_data(
+            files=files, post_data=post_data, raw=False
+        )
+        assert result.json is None
+        assert result.content_type.startswith("multipart/form-data")
+        assert isinstance(result.data, MultipartEncoder)
+        assert isinstance(result.data.fields["test_data"], str)
+        assert result.data.fields["test_data"] == expected
diff --git a/tests/unit/base/test_rest_manager.py b/tests/unit/base/test_rest_manager.py
new file mode 100644
index 000000000..3605cfb48
--- /dev/null
+++ b/tests/unit/base/test_rest_manager.py
@@ -0,0 +1,30 @@
+from gitlab import base
+from tests.unit import helpers
+
+
+def test_computed_path_simple(gl):
+    class MGR(base.RESTManager):
+        _path = "/tests"
+        _obj_cls = object
+
+    mgr = MGR(gl)
+    assert mgr._computed_path == "/tests"
+
+
+def test_computed_path_with_parent(gl, fake_manager):
+    class MGR(base.RESTManager):
+        _path = "/tests/{test_id}/cases"
+        _obj_cls = object
+        _from_parent_attrs = {"test_id": "id"}
+
+    mgr = MGR(gl, parent=helpers.FakeParent(manager=fake_manager, attrs={}))
+    assert mgr._computed_path == "/tests/42/cases"
+
+
+def test_path_property(gl):
+    class MGR(base.RESTManager):
+        _path = "/tests"
+        _obj_cls = object
+
+    mgr = MGR(gl)
+    assert mgr.path == "/tests"
diff --git a/tests/unit/base/test_rest_object.py b/tests/unit/base/test_rest_object.py
new file mode 100644
index 000000000..054379f3c
--- /dev/null
+++ b/tests/unit/base/test_rest_object.py
@@ -0,0 +1,334 @@
+from __future__ import annotations
+
+import pickle
+
+import pytest
+
+import gitlab
+from gitlab import base
+from tests.unit import helpers
+from tests.unit.helpers import FakeManager  # noqa: F401, needed for _create_managers
+
+
+def test_instantiate(gl, fake_manager):
+    attrs = {"foo": "bar"}
+    obj = helpers.FakeObject(fake_manager, attrs.copy())
+
+    assert attrs == obj._attrs
+    assert {} == obj._updated_attrs
+    assert obj._create_managers() is None
+    assert fake_manager == obj.manager
+    assert gl == obj.manager.gitlab
+    assert str(obj) == f"{type(obj)} => {attrs}"
+
+
+def test_instantiate_non_dict(gl, fake_manager):
+    with pytest.raises(gitlab.exceptions.GitlabParsingError):
+        helpers.FakeObject(fake_manager, ["a", "list", "fails"])
+
+
+def test_missing_attribute_does_not_raise_custom(gl, fake_manager):
+    """Ensure a missing attribute does not raise our custom error message
+    if the RESTObject was not created from a list"""
+    obj = helpers.FakeObject(manager=fake_manager, attrs={"foo": "bar"})
+    with pytest.raises(AttributeError) as excinfo:
+        obj.missing_attribute
+    exc_str = str(excinfo.value)
+    assert "missing_attribute" in exc_str
+    assert "was created via a list()" not in exc_str
+    assert base._URL_ATTRIBUTE_ERROR not in exc_str
+
+
+def test_missing_attribute_from_list_raises_custom(gl, fake_manager):
+    """Ensure a missing attribute raises our custom error message if the
+    RESTObject was created from a list"""
+    obj = helpers.FakeObject(
+        manager=fake_manager, attrs={"foo": "bar"}, created_from_list=True
+    )
+    with pytest.raises(AttributeError) as excinfo:
+        obj.missing_attribute
+    exc_str = str(excinfo.value)
+    assert "missing_attribute" in exc_str
+    assert "was created via a list()" in exc_str
+    assert base._URL_ATTRIBUTE_ERROR in exc_str
+
+
+def test_picklability(fake_manager):
+    obj = helpers.FakeObject(fake_manager, {"foo": "bar"})
+    original_obj_module = obj._module
+    pickled = pickle.dumps(obj)
+    unpickled = pickle.loads(pickled)
+    assert isinstance(unpickled, helpers.FakeObject)
+    assert hasattr(unpickled, "_module")
+    assert unpickled._module == original_obj_module
+    pickle.dumps(unpickled)
+
+
+def test_attrs(fake_manager):
+    obj = helpers.FakeObject(fake_manager, {"foo": "bar"})
+
+    assert "bar" == obj.foo
+    with pytest.raises(AttributeError):
+        getattr(obj, "bar")
+
+    obj.bar = "baz"
+    assert "baz" == obj.bar
+    assert {"foo": "bar"} == obj._attrs
+    assert {"bar": "baz"} == obj._updated_attrs
+
+
+def test_get_id(fake_manager):
+    obj = helpers.FakeObject(fake_manager, {"foo": "bar"})
+    obj.id = 42
+    assert 42 == obj.get_id()
+
+    obj.id = None
+    assert obj.get_id() is None
+
+
+def test_encoded_id(fake_manager):
+    obj = helpers.FakeObject(fake_manager, {"foo": "bar"})
+    obj.id = 42
+    assert 42 == obj.encoded_id
+
+    obj.id = None
+    assert obj.encoded_id is None
+
+    obj.id = "plain"
+    assert "plain" == obj.encoded_id
+
+    obj.id = "a/path"
+    assert "a%2Fpath" == obj.encoded_id
+
+    # If you assign it again it does not double URL-encode
+    obj.id = obj.encoded_id
+    assert "a%2Fpath" == obj.encoded_id
+
+
+def test_custom_id_attr(fake_manager):
+    obj = helpers.OtherFakeObject(fake_manager, {"foo": "bar"})
+    assert "bar" == obj.get_id()
+
+
+def test_update_attrs(fake_manager):
+    obj = helpers.FakeObject(fake_manager, {"foo": "bar"})
+    obj.bar = "baz"
+    obj._update_attrs({"foo": "foo", "bar": "bar"})
+    assert {"foo": "foo", "bar": "bar"} == obj._attrs
+    assert {} == obj._updated_attrs
+
+
+def test_update_attrs_deleted(fake_manager):
+    obj = helpers.FakeObject(fake_manager, {"foo": "foo", "bar": "bar"})
+    obj.bar = "baz"
+    obj._update_attrs({"foo": "foo"})
+    assert {"foo": "foo"} == obj._attrs
+    assert {} == obj._updated_attrs
+
+
+def test_dir_unique(fake_manager):
+    obj = helpers.FakeObject(fake_manager, {"manager": "foo"})
+    assert len(dir(obj)) == len(set(dir(obj)))
+
+
+def test_create_managers(gl, fake_manager):
+    class ObjectWithManager(helpers.FakeObject):
+        fakes: FakeManager
+
+    obj = ObjectWithManager(fake_manager, {"foo": "bar"})
+    obj.id = 42
+    assert isinstance(obj.fakes, helpers.FakeManager)
+    assert obj.fakes.gitlab == gl
+    assert obj.fakes._parent == obj
+
+
+def test_equality(fake_manager):
+    obj1 = helpers.FakeObject(fake_manager, {"id": "foo"})
+    obj2 = helpers.FakeObject(fake_manager, {"id": "foo", "other_attr": "bar"})
+    assert obj1 == obj2
+    assert len({obj1, obj2}) == 1
+
+
+def test_equality_custom_id(fake_manager):
+    obj1 = helpers.OtherFakeObject(fake_manager, {"foo": "bar"})
+    obj2 = helpers.OtherFakeObject(fake_manager, {"foo": "bar", "other_attr": "baz"})
+    assert obj1 == obj2
+
+
+def test_equality_no_id(fake_manager):
+    obj1 = helpers.FakeObject(fake_manager, {"attr1": "foo"})
+    obj2 = helpers.FakeObject(fake_manager, {"attr1": "bar"})
+    assert not obj1 == obj2
+
+
+def test_inequality(fake_manager):
+    obj1 = helpers.FakeObject(fake_manager, {"id": "foo"})
+    obj2 = helpers.FakeObject(fake_manager, {"id": "bar"})
+    assert obj1 != obj2
+
+
+def test_inequality_no_id(fake_manager):
+    obj1 = helpers.FakeObject(fake_manager, {"attr1": "foo"})
+    obj2 = helpers.FakeObject(fake_manager, {"attr1": "bar"})
+    assert obj1 != obj2
+    assert len({obj1, obj2}) == 2
+
+
+def test_equality_with_other_objects(fake_manager):
+    obj1 = helpers.FakeObject(fake_manager, {"id": "foo"})
+    obj2 = None
+    assert not obj1 == obj2
+
+
+def test_dunder_str(fake_manager):
+    fake_object = helpers.FakeObject(fake_manager, {"attr1": "foo"})
+    assert str(fake_object) == (
+        "<class 'tests.unit.helpers.FakeObject'> => {'attr1': 'foo'}"
+    )
+
+
+@pytest.mark.parametrize(
+    "id_attr,repr_attr, attrs, expected_repr",
+    [
+        ("id", None, {"id": 1}, "<ReprObject id:1>"),
+        ("id", "name", {"id": 1, "name": "fake"}, "<ReprObject id:1 name:fake>"),
+        ("name", "name", {"name": "fake"}, "<ReprObject name:fake>"),
+        ("id", "name", {"id": 1}, "<ReprObject id:1>"),
+        (None, None, {}, "<ReprObject>"),
+        (None, "name", {"name": "fake"}, "<ReprObject name:fake>"),
+        (None, "name", {}, "<ReprObject>"),
+    ],
+    ids=[
+        "GetMixin with id",
+        "GetMixin with id and _repr_attr",
+        "GetMixin with _repr_attr matching _id_attr",
+        "GetMixin with _repr_attr without _repr_attr value defined",
+        "GetWithoutIDMixin",
+        "GetWithoutIDMixin with _repr_attr",
+        "GetWithoutIDMixin with _repr_attr without _repr_attr value defined",
+    ],
+)
+def test_dunder_repr(fake_manager, id_attr, repr_attr, attrs, expected_repr):
+    class ReprObject(helpers.FakeObject):
+        _id_attr = id_attr
+        _repr_attr = repr_attr
+
+    fake_object = ReprObject(fake_manager, attrs)
+
+    assert repr(fake_object) == expected_repr
+
+
+def test_pformat(fake_manager):
+    fake_object = helpers.FakeObject(
+        fake_manager, {"attr1": "foo" * 10, "ham": "eggs" * 15}
+    )
+    assert fake_object.pformat() == (
+        "<class 'tests.unit.helpers.FakeObject'> => "
+        "\n{'attr1': 'foofoofoofoofoofoofoofoofoofoo',\n"
+        " 'ham': 'eggseggseggseggseggseggseggseggseggseggseggseggseggseggseggs'}"
+    )
+
+
+def test_pprint(capfd, fake_manager):
+    fake_object = helpers.FakeObject(
+        fake_manager, {"attr1": "foo" * 10, "ham": "eggs" * 15}
+    )
+    result = fake_object.pprint()
+    assert result is None
+    stdout, stderr = capfd.readouterr()
+    assert stdout == (
+        "<class 'tests.unit.helpers.FakeObject'> => "
+        "\n{'attr1': 'foofoofoofoofoofoofoofoofoofoo',\n"
+        " 'ham': 'eggseggseggseggseggseggseggseggseggseggseggseggseggseggseggs'}\n"
+    )
+    assert stderr == ""
+
+
+def test_repr(fake_manager):
+    attrs = {"attr1": "foo"}
+    obj = helpers.FakeObject(fake_manager, attrs)
+    assert repr(obj) == "<FakeObject id:None>"
+
+    helpers.FakeObject._id_attr = None
+    assert repr(obj) == "<FakeObject>"
+
+
+def test_attributes_get(fake_object):
+    assert fake_object.attr1 == "foo"
+    result = fake_object.attributes
+    assert result == {"attr1": "foo", "alist": [1, 2, 3]}
+
+
+def test_attributes_shows_updates(fake_object):
+    # Updated attribute value is reflected in `attributes`
+    fake_object.attr1 = "hello"
+    assert fake_object.attributes == {"attr1": "hello", "alist": [1, 2, 3]}
+    assert fake_object.attr1 == "hello"
+    # New attribute is in `attributes`
+    fake_object.new_attrib = "spam"
+    assert fake_object.attributes == {
+        "attr1": "hello",
+        "new_attrib": "spam",
+        "alist": [1, 2, 3],
+    }
+
+
+def test_attributes_is_copy(fake_object):
+    # Modifying the dictionary does not cause modifications to the object
+    result = fake_object.attributes
+    result["alist"].append(10)
+    assert result == {"attr1": "foo", "alist": [1, 2, 3, 10]}
+    assert fake_object.attributes == {"attr1": "foo", "alist": [1, 2, 3]}
+
+
+def test_attributes_has_parent_attrs(fake_object_with_parent):
+    assert fake_object_with_parent.attr1 == "foo"
+    result = fake_object_with_parent.attributes
+    assert result == {"attr1": "foo", "alist": [1, 2, 3], "test_id": "42"}
+
+
+def test_to_json(fake_object):
+    assert fake_object.attr1 == "foo"
+    result = fake_object.to_json()
+    assert result == '{"attr1": "foo", "alist": [1, 2, 3]}'
+
+
+def test_asdict(fake_object):
+    assert fake_object.attr1 == "foo"
+    result = fake_object.asdict()
+    assert result == {"attr1": "foo", "alist": [1, 2, 3]}
+
+
+def test_asdict_no_parent_attrs(fake_object_with_parent):
+    assert fake_object_with_parent.attr1 == "foo"
+    result = fake_object_with_parent.asdict()
+    assert result == {"attr1": "foo", "alist": [1, 2, 3]}
+    assert "test_id" not in fake_object_with_parent.asdict()
+    assert "test_id" not in fake_object_with_parent.asdict(with_parent_attrs=False)
+    assert "test_id" in fake_object_with_parent.asdict(with_parent_attrs=True)
+
+
+def test_asdict_modify_dict_does_not_change_object(fake_object):
+    result = fake_object.asdict()
+    # Demonstrate modifying the dictionary does not modify the object
+    result["attr1"] = "testing"
+    result["alist"].append(4)
+    assert result == {"attr1": "testing", "alist": [1, 2, 3, 4]}
+    assert fake_object.attr1 == "foo"
+    assert fake_object.alist == [1, 2, 3]
+
+
+def test_asdict_modify_dict_does_not_change_object2(fake_object):
+    # Modify attribute and then ensure modifying a list in the returned dict won't
+    # modify the list in the object.
+    fake_object.attr1 = [9, 7, 8]
+    assert fake_object.asdict() == {"attr1": [9, 7, 8], "alist": [1, 2, 3]}
+    result = fake_object.asdict()
+    result["attr1"].append(1)
+    assert fake_object.asdict() == {"attr1": [9, 7, 8], "alist": [1, 2, 3]}
+
+
+def test_asdict_modify_object(fake_object):
+    # asdict() returns the updated value
+    fake_object.attr1 = "spam"
+    assert fake_object.asdict() == {"attr1": "spam", "alist": [1, 2, 3]}
diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
new file mode 100644
index 000000000..bbfd4c230
--- /dev/null
+++ b/tests/unit/conftest.py
@@ -0,0 +1,158 @@
+import pytest
+import responses
+
+import gitlab
+from tests.unit import helpers
+
+
+@pytest.fixture
+def fake_manager(gl):
+    return helpers.FakeManager(gl)
+
+
+@pytest.fixture
+def fake_manager_with_parent(gl, fake_manager):
+    return helpers.FakeManagerWithParent(
+        gl, parent=helpers.FakeParent(manager=fake_manager, attrs={})
+    )
+
+
+@pytest.fixture
+def fake_object(fake_manager):
+    return helpers.FakeObject(fake_manager, {"attr1": "foo", "alist": [1, 2, 3]})
+
+
+@pytest.fixture
+def fake_object_no_id(fake_manager):
+    return helpers.FakeObjectWithoutId(fake_manager, {})
+
+
+@pytest.fixture
+def fake_object_long_repr(fake_manager):
+    return helpers.FakeObjectWithLongRepr(fake_manager, {"test": "a" * 100})
+
+
+@pytest.fixture
+def fake_object_with_parent(fake_manager_with_parent):
+    return helpers.FakeObject(
+        fake_manager_with_parent, {"attr1": "foo", "alist": [1, 2, 3]}
+    )
+
+
+@pytest.fixture
+def gl():
+    return gitlab.Gitlab(
+        "http://localhost",
+        private_token="private_token",
+        ssl_verify=True,
+        api_version="4",
+    )
+
+
+@pytest.fixture
+def gl_retry():
+    return gitlab.Gitlab(
+        "http://localhost",
+        private_token="private_token",
+        ssl_verify=True,
+        api_version="4",
+        retry_transient_errors=True,
+    )
+
+
+@pytest.fixture
+def resp_get_current_user():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/user",
+            json={
+                "id": 1,
+                "username": "username",
+                "web_url": "http://localhost/username",
+            },
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+# Todo: parametrize, but check what tests it's really useful for
+@pytest.fixture
+def gl_trailing():
+    return gitlab.Gitlab(
+        "http://localhost/", private_token="private_token", api_version="4"
+    )
+
+
+@pytest.fixture
+def default_config(tmpdir):
+    valid_config = """[global]
+    default = one
+    ssl_verify = true
+    timeout = 2
+
+    [one]
+    url = http://one.url
+    private_token = ABCDEF
+    """
+
+    config_path = tmpdir.join("python-gitlab.cfg")
+    config_path.write(valid_config)
+    return str(config_path)
+
+
+@pytest.fixture
+def tag_name():
+    return "v1.0.0"
+
+
+@pytest.fixture
+def group(gl):
+    return gl.groups.get(1, lazy=True)
+
+
+@pytest.fixture
+def project(gl):
+    return gl.projects.get(1, lazy=True)
+
+
+@pytest.fixture
+def another_project(gl):
+    return gl.projects.get(2, lazy=True)
+
+
+@pytest.fixture
+def project_issue(project):
+    return project.issues.get(1, lazy=True)
+
+
+@pytest.fixture
+def project_merge_request(project):
+    return project.mergerequests.get(1, lazy=True)
+
+
+@pytest.fixture
+def release(project, tag_name):
+    return project.releases.get(tag_name, lazy=True)
+
+
+@pytest.fixture
+def schedule(project):
+    return project.pipelineschedules.get(1, lazy=True)
+
+
+@pytest.fixture
+def user(gl):
+    return gl.users.get(1, lazy=True)
+
+
+@pytest.fixture
+def current_user(gl, resp_get_current_user):
+    gl.auth()
+    return gl.user
+
+
+@pytest.fixture
+def migration(gl):
+    return gl.bulk_imports.get(1, lazy=True)
diff --git a/tests/unit/helpers.py b/tests/unit/helpers.py
new file mode 100644
index 000000000..717108d44
--- /dev/null
+++ b/tests/unit/helpers.py
@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+import datetime
+import io
+import json
+
+import requests
+import responses
+
+from gitlab import base
+
+MATCH_EMPTY_QUERY_PARAMS = [responses.matchers.query_param_matcher({})]
+
+
+class FakeObject(base.RESTObject):
+    pass
+
+
+class FakeObjectWithoutId(base.RESTObject):
+    _id_attr = None
+
+
+class FakeObjectWithLongRepr(base.RESTObject):
+    _id_attr = None
+    _repr_attr = "test"
+
+
+class OtherFakeObject(FakeObject):
+    _id_attr = "foo"
+
+
+class FakeManager(base.RESTManager):
+    _path = "/tests"
+    _obj_cls = FakeObject
+
+
+class FakeParent(FakeObject):
+    id = 42
+
+
+class FakeManagerWithParent(base.RESTManager):
+    _path = "/tests/{test_id}/cases"
+    _obj_cls = FakeObject
+    _from_parent_attrs = {"test_id": "id"}
+
+
+# NOTE: The function `httmock_response` and the class `Headers` is taken from
+# https://github.com/patrys/httmock/ which is licensed under the Apache License, Version
+# 2.0. Thus it is allowed to be used in this project.
+# https://www.apache.org/licenses/GPL-compatibility.html
+class Headers:
+    def __init__(self, res):
+        self.headers = res.headers
+
+    def get_all(self, name, failobj=None):
+        return self.getheaders(name)
+
+    def getheaders(self, name):
+        return [self.headers.get(name)]
+
+
+def httmock_response(
+    status_code: int = 200,
+    content: str = "",
+    headers=None,
+    reason=None,
+    elapsed=0,
+    request: requests.models.PreparedRequest | None = None,
+    stream: bool = False,
+    http_vsn=11,
+) -> requests.models.Response:
+    res = requests.Response()
+    res.status_code = status_code
+    if isinstance(content, (dict, list)):
+        content = json.dumps(content).encode("utf-8")
+    if isinstance(content, str):
+        content = content.encode("utf-8")
+    res._content = content
+    res._content_consumed = content
+    res.headers = requests.structures.CaseInsensitiveDict(headers or {})
+    res.encoding = requests.utils.get_encoding_from_headers(res.headers)
+    res.reason = reason
+    res.elapsed = datetime.timedelta(elapsed)
+    res.request = request
+    if hasattr(request, "url"):
+        res.url = request.url
+        if isinstance(request.url, bytes):
+            res.url = request.url.decode("utf-8")
+    if "set-cookie" in res.headers:
+        res.cookies.extract_cookies(
+            requests.cookies.MockResponse(Headers(res)),
+            requests.cookies.MockRequest(request),
+        )
+    if stream:
+        res.raw = io.BytesIO(content)
+    else:
+        res.raw = io.BytesIO(b"")
+    res.raw.version = http_vsn
+
+    # normally this closes the underlying connection,
+    #  but we have nothing to free.
+    res.close = lambda *args, **kwargs: None
+
+    return res
diff --git a/tests/unit/meta/__init__.py b/tests/unit/meta/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/meta/test_abstract_attrs.py b/tests/unit/meta/test_abstract_attrs.py
new file mode 100644
index 000000000..e43a81b7b
--- /dev/null
+++ b/tests/unit/meta/test_abstract_attrs.py
@@ -0,0 +1,41 @@
+"""
+Ensure that RESTManager subclasses exported to gitlab.v4.objects
+are defining the _path and _obj_cls attributes.
+
+Only check using `hasattr` as if incorrect type is assigned the type
+checker will raise an error.
+"""
+
+from __future__ import annotations
+
+from inspect import getmembers
+
+import gitlab.v4.objects
+from gitlab.base import RESTManager
+
+
+def test_rest_manager_abstract_attrs() -> None:
+    without_path: list[str] = []
+    without_obj_cls: list[str] = []
+
+    for key, member in getmembers(gitlab.v4.objects):
+        if not isinstance(member, type):
+            continue
+
+        if not issubclass(member, RESTManager):
+            continue
+
+        if not hasattr(member, "_path"):
+            without_path.append(key)
+
+        if not hasattr(member, "_obj_cls"):
+            without_obj_cls.append(key)
+
+    assert not without_path, (
+        "RESTManager subclasses missing '_path' attribute: "
+        f"{', '.join(without_path)}"
+    )
+    assert not without_obj_cls, (
+        "RESTManager subclasses missing '_obj_cls' attribute: "
+        f"{', '.join(without_obj_cls)}"
+    )
diff --git a/tests/unit/meta/test_imports.py b/tests/unit/meta/test_imports.py
new file mode 100644
index 000000000..d49f3e495
--- /dev/null
+++ b/tests/unit/meta/test_imports.py
@@ -0,0 +1,44 @@
+"""
+Ensure objects defined in gitlab.v4.objects are imported in
+`gitlab/v4/objects/__init__.py`
+
+"""
+
+import pkgutil
+from typing import Set
+
+import gitlab.exceptions
+import gitlab.v4.objects
+
+
+def test_all_exceptions_imports_are_exported() -> None:
+    assert gitlab.exceptions.__all__ == sorted(
+        [
+            name
+            for name in dir(gitlab.exceptions)
+            if name.endswith("Error") and not name.startswith("_")
+        ]
+    )
+
+
+def test_all_v4_objects_are_imported() -> None:
+    assert len(gitlab.v4.objects.__path__) == 1
+
+    init_files: Set[str] = set()
+    with open(gitlab.v4.objects.__file__, encoding="utf-8") as in_file:
+        for line in in_file.readlines():
+            if line.startswith("from ."):
+                init_files.add(line.rstrip())
+
+    object_files = set()
+    for module in pkgutil.iter_modules(gitlab.v4.objects.__path__):
+        object_files.add(f"from .{module.name} import *")
+
+    missing_in_init = object_files - init_files
+    error_message = (
+        f"\nThe file {gitlab.v4.objects.__file__!r} is missing the following imports:"
+    )
+    for missing in sorted(missing_in_init):
+        error_message += f"\n    {missing}"
+
+    assert not missing_in_init, error_message
diff --git a/tests/unit/meta/test_mro.py b/tests/unit/meta/test_mro.py
new file mode 100644
index 000000000..1b64003d0
--- /dev/null
+++ b/tests/unit/meta/test_mro.py
@@ -0,0 +1,124 @@
+"""
+Ensure objects defined in gitlab.v4.objects have REST* as last item in class
+definition
+
+Original notes by John L. Villalovos
+
+An example of an incorrect definition:
+    class ProjectPipeline(RESTObject, RefreshMixin, ObjectDeleteMixin):
+                          ^^^^^^^^^^ This should be at the end.
+
+Correct way would be:
+    class ProjectPipeline(RefreshMixin, ObjectDeleteMixin, RESTObject):
+                                      Correctly at the end ^^^^^^^^^^
+
+
+Why this is an issue:
+
+  When we do type-checking for gitlab/mixins.py we make RESTObject or
+  RESTManager the base class for the mixins
+
+  Here is how our classes look when type-checking:
+
+      class RESTObject:
+          def __init__(self, manager: "RESTManager", attrs: Dict[str, Any]) -> None:
+              ...
+
+      class Mixin(RESTObject):
+          ...
+
+      # Wrong ordering here
+      class Wrongv4Object(RESTObject, RefreshMixin):
+          ...
+
+  If we actually ran this in Python we would get the following error:
+         class Wrongv4Object(RESTObject, Mixin):
+    TypeError: Cannot create a consistent method resolution
+    order (MRO) for bases RESTObject, Mixin
+
+  When we are type-checking it fails to understand the class Wrongv4Object
+  and thus we can't type check it correctly.
+
+Almost all classes in gitlab/v4/objects/*py were already correct before this
+check was added.
+"""
+
+import inspect
+from typing import Generic
+
+import pytest
+
+import gitlab.v4.objects
+
+
+def test_show_issue() -> None:
+    """Test case to demonstrate the TypeError that occurs"""
+
+    class RESTObject:
+        def __init__(self, manager: str, attrs: int) -> None: ...
+
+    class Mixin(RESTObject): ...
+
+    with pytest.raises(TypeError) as exc_info:
+        # Wrong ordering here
+        class Wrongv4Object(RESTObject, Mixin):  # type: ignore
+            ...
+
+    # The error message in the exception should be:
+    #   TypeError: Cannot create a consistent method resolution
+    #   order (MRO) for bases RESTObject, Mixin
+
+    # Make sure the exception string contains "MRO"
+    assert "MRO" in exc_info.exconly()
+
+    # Correctly ordered class, no exception
+    class Correctv4Object(Mixin, RESTObject): ...
+
+
+def test_mros() -> None:
+    """Ensure objects defined in gitlab.v4.objects have REST* as last item in
+    class definition.
+
+    We do this as we need to ensure the MRO (Method Resolution Order) is
+    correct.
+    """
+
+    failed_messages = []
+    for module_name, module_value in inspect.getmembers(gitlab.v4.objects):
+        if not inspect.ismodule(module_value):
+            # We only care about the modules
+            continue
+        # Iterate through all the classes in our module
+        for class_name, class_value in inspect.getmembers(module_value):
+            if not inspect.isclass(class_value):
+                continue
+
+            # Ignore imported classes from gitlab.base
+            if class_value.__module__ == "gitlab.base":
+                continue
+
+            mro = class_value.mro()
+
+            # We only check classes which have a 'gitlab.base' class in their MRO
+            has_base = False
+            for count, obj in enumerate(mro, start=1):
+                if obj.__module__ == "gitlab.base":
+                    has_base = True
+                    base_classname = obj.__name__
+            if has_base:
+                filename = inspect.getfile(class_value)
+                # NOTE(jlvillal): The very last item 'mro[-1]' is always going
+                # to be 'object'. The second to last might be typing.Generic.
+                # That is why we are checking either 'mro[-3]' or 'mro[-2]'.
+                index_to_check = -2
+                if mro[index_to_check] == Generic:
+                    index_to_check -= 1
+
+                if mro[index_to_check].__module__ != "gitlab.base":
+                    failed_messages.append(
+                        f"class definition for {class_name!r} in file {filename!r} "
+                        f"must have {base_classname!r} as the last class in the "
+                        f"class definition"
+                    )
+    failed_msg = "\n".join(failed_messages)
+    assert not failed_messages, failed_msg
diff --git a/tests/unit/mixins/__init__.py b/tests/unit/mixins/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/mixins/test_meta_mixins.py b/tests/unit/mixins/test_meta_mixins.py
new file mode 100644
index 000000000..5144a17bc
--- /dev/null
+++ b/tests/unit/mixins/test_meta_mixins.py
@@ -0,0 +1,63 @@
+from unittest.mock import MagicMock
+
+from gitlab.mixins import (
+    CreateMixin,
+    CRUDMixin,
+    DeleteMixin,
+    GetMixin,
+    ListMixin,
+    NoUpdateMixin,
+    RetrieveMixin,
+    UpdateMixin,
+)
+
+
+def test_retrieve_mixin():
+    class M(RetrieveMixin):
+        _obj_cls = object
+        _path = "/test"
+
+    obj = M(MagicMock())
+    assert hasattr(obj, "list")
+    assert hasattr(obj, "get")
+    assert not hasattr(obj, "create")
+    assert not hasattr(obj, "update")
+    assert not hasattr(obj, "delete")
+    assert isinstance(obj, ListMixin)
+    assert isinstance(obj, GetMixin)
+
+
+def test_crud_mixin():
+    class M(CRUDMixin):
+        _obj_cls = object
+        _path = "/test"
+
+    obj = M(MagicMock())
+    assert hasattr(obj, "get")
+    assert hasattr(obj, "list")
+    assert hasattr(obj, "create")
+    assert hasattr(obj, "update")
+    assert hasattr(obj, "delete")
+    assert isinstance(obj, ListMixin)
+    assert isinstance(obj, GetMixin)
+    assert isinstance(obj, CreateMixin)
+    assert isinstance(obj, UpdateMixin)
+    assert isinstance(obj, DeleteMixin)
+
+
+def test_no_update_mixin():
+    class M(NoUpdateMixin):
+        _obj_cls = object
+        _path = "/test"
+
+    obj = M(MagicMock())
+    assert hasattr(obj, "get")
+    assert hasattr(obj, "list")
+    assert hasattr(obj, "create")
+    assert not hasattr(obj, "update")
+    assert hasattr(obj, "delete")
+    assert isinstance(obj, ListMixin)
+    assert isinstance(obj, GetMixin)
+    assert isinstance(obj, CreateMixin)
+    assert not isinstance(obj, UpdateMixin)
+    assert isinstance(obj, DeleteMixin)
diff --git a/tests/unit/mixins/test_mixin_methods.py b/tests/unit/mixins/test_mixin_methods.py
new file mode 100644
index 000000000..fb6ded881
--- /dev/null
+++ b/tests/unit/mixins/test_mixin_methods.py
@@ -0,0 +1,598 @@
+from unittest.mock import mock_open, patch
+
+import pytest
+import requests
+import responses
+
+from gitlab import base, GitlabUploadError
+from gitlab import types as gl_types
+from gitlab.mixins import (
+    CreateMixin,
+    DeleteMixin,
+    GetMixin,
+    GetWithoutIdMixin,
+    ListMixin,
+    RefreshMixin,
+    SaveMixin,
+    SetMixin,
+    UpdateMethod,
+    UpdateMixin,
+    UploadMixin,
+)
+
+
+class FakeObject(base.RESTObject):
+    pass
+
+
+class FakeManager(base.RESTManager):
+    _path = "/tests"
+    _obj_cls = FakeObject
+
+
+@responses.activate
+def test_get_mixin(gl):
+    class M(GetMixin, FakeManager):
+        pass
+
+    url = "http://localhost/api/v4/tests/42"
+    responses.add(
+        method=responses.GET,
+        url=url,
+        json={"id": 42, "foo": "bar"},
+        status=200,
+        match=[responses.matchers.query_param_matcher({})],
+    )
+
+    mgr = M(gl)
+    obj = mgr.get(42)
+    assert isinstance(obj, FakeObject)
+    assert obj.foo == "bar"
+    assert obj.id == 42
+    assert obj._lazy is False
+    assert responses.assert_call_count(url, 1) is True
+
+
+def test_get_mixin_lazy(gl):
+    class M(GetMixin, FakeManager):
+        pass
+
+    url = "http://localhost/api/v4/tests/42"
+
+    mgr = M(gl)
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=url,
+            json={"id": 42, "foo": "bar"},
+            status=200,
+            match=[responses.matchers.query_param_matcher({})],
+        )
+        obj = mgr.get(42, lazy=True)
+    assert isinstance(obj, FakeObject)
+    assert not hasattr(obj, "foo")
+    assert obj.id == 42
+    assert obj._lazy is True
+    # a `lazy` get does not make a network request
+    assert not rsps.calls
+
+
+def test_get_mixin_lazy_missing_attribute(gl):
+    class FakeGetManager(GetMixin, FakeManager):
+        pass
+
+    manager = FakeGetManager(gl)
+    obj = manager.get(1, lazy=True)
+    assert obj.id == 1
+    with pytest.raises(AttributeError) as exc:
+        obj.missing_attribute
+    # undo `textwrap.fill()`
+    message = str(exc.value).replace("\n", " ")
+    assert "'FakeObject' object has no attribute 'missing_attribute'" in message
+    assert (
+        "note that <class 'tests.unit.mixins.test_mixin_methods.FakeObject'> was "
+        "created as a `lazy` object and was not initialized with any data."
+    ) in message
+
+
+@responses.activate
+def test_head_mixin(gl):
+    class M(GetMixin, FakeManager):
+        pass
+
+    url = "http://localhost/api/v4/tests/42"
+    responses.add(
+        method=responses.HEAD,
+        url=url,
+        headers={"X-GitLab-Header": "test"},
+        status=200,
+        match=[responses.matchers.query_param_matcher({})],
+    )
+
+    manager = M(gl)
+    result = manager.head(42)
+    assert isinstance(result, requests.structures.CaseInsensitiveDict)
+    assert result["x-gitlab-header"] == "test"
+
+
+@responses.activate
+def test_refresh_mixin(gl):
+    class TestClass(RefreshMixin, FakeObject):
+        pass
+
+    url = "http://localhost/api/v4/tests/42"
+    responses.add(
+        method=responses.GET,
+        url=url,
+        json={"id": 42, "foo": "bar"},
+        status=200,
+        match=[responses.matchers.query_param_matcher({})],
+    )
+
+    mgr = FakeManager(gl)
+    obj = TestClass(mgr, {"id": 42})
+    res = obj.refresh()
+    assert res is None
+    assert obj.foo == "bar"
+    assert obj.id == 42
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_get_without_id_mixin(gl):
+    class M(GetWithoutIdMixin, FakeManager):
+        pass
+
+    url = "http://localhost/api/v4/tests"
+    responses.add(
+        method=responses.GET,
+        url=url,
+        json={"foo": "bar"},
+        status=200,
+        match=[responses.matchers.query_param_matcher({})],
+    )
+
+    mgr = M(gl)
+    obj = mgr.get()
+    assert isinstance(obj, FakeObject)
+    assert obj.foo == "bar"
+    assert not hasattr(obj, "id")
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_list_mixin(gl):
+    class M(ListMixin, FakeManager):
+        pass
+
+    url = "http://localhost/api/v4/tests"
+    headers = {
+        "X-Page": "1",
+        "X-Next-Page": "2",
+        "X-Per-Page": "1",
+        "X-Total-Pages": "2",
+        "X-Total": "2",
+        "Link": ("<http://localhost/api/v4/tests" ' rel="next"'),
+    }
+    responses.add(
+        method=responses.GET,
+        headers=headers,
+        url=url,
+        json=[{"id": 42, "foo": "bar"}, {"id": 43, "foo": "baz"}],
+        status=200,
+        match=[responses.matchers.query_param_matcher({})],
+    )
+
+    # test RESTObjectList
+    mgr = M(gl)
+    obj_list = mgr.list(iterator=True)
+    assert isinstance(obj_list, base.RESTObjectList)
+    assert obj_list.current_page == 1
+    assert obj_list.prev_page is None
+    assert obj_list.next_page == 2
+    assert obj_list.per_page == 1
+    assert obj_list.total == 2
+    assert obj_list.total_pages == 2
+    assert len(obj_list) == 2
+
+    for obj in obj_list:
+        assert isinstance(obj, FakeObject)
+        assert obj.id in (42, 43)
+
+    # test list()
+    obj_list = mgr.list(get_all=True)
+    assert isinstance(obj_list, list)
+    assert obj_list[0].id == 42
+    assert obj_list[1].id == 43
+    assert isinstance(obj_list[0], FakeObject)
+    assert len(obj_list) == 2
+    assert responses.assert_call_count(url, 2) is True
+
+
+@responses.activate
+def test_list_mixin_with_attributes(gl):
+    class M(ListMixin, FakeManager):
+        _types = {"my_array": gl_types.ArrayAttribute}
+
+    url = "http://localhost/api/v4/tests"
+    responses.add(
+        method=responses.GET,
+        headers={},
+        url=url,
+        json=[],
+        status=200,
+        match=[responses.matchers.query_param_matcher({"my_array[]": ["1", "2", "3"]})],
+    )
+
+    mgr = M(gl)
+    mgr.list(iterator=True, my_array=[1, 2, 3])
+
+
+@responses.activate
+def test_list_other_url(gl):
+    class M(ListMixin, FakeManager):
+        pass
+
+    url = "http://localhost/api/v4/others"
+    responses.add(
+        method=responses.GET,
+        url=url,
+        json=[{"id": 42, "foo": "bar"}],
+        status=200,
+        match=[responses.matchers.query_param_matcher({})],
+    )
+
+    mgr = M(gl)
+    obj_list = mgr.list(path="/others", iterator=True)
+    assert isinstance(obj_list, base.RESTObjectList)
+    obj = obj_list.next()
+    assert obj.id == 42
+    assert obj.foo == "bar"
+    with pytest.raises(StopIteration):
+        obj_list.next()
+
+
+def test_create_mixin_missing_attrs(gl):
+    class M(CreateMixin, FakeManager):
+        _create_attrs = gl_types.RequiredOptional(
+            required=("foo",), optional=("bar", "baz")
+        )
+
+    mgr = M(gl)
+    data = {"foo": "bar", "baz": "blah"}
+    mgr._create_attrs.validate_attrs(data=data)
+
+    data = {"baz": "blah"}
+    with pytest.raises(AttributeError) as error:
+        mgr._create_attrs.validate_attrs(data=data)
+    assert "foo" in str(error.value)
+
+
+@responses.activate
+def test_create_mixin(gl):
+    class M(CreateMixin, FakeManager):
+        _create_attrs = gl_types.RequiredOptional(
+            required=("foo",), optional=("bar", "baz")
+        )
+        _update_attrs = gl_types.RequiredOptional(required=("foo",), optional=("bam",))
+
+    url = "http://localhost/api/v4/tests"
+    responses.add(
+        method=responses.POST,
+        url=url,
+        json={"id": 42, "foo": "bar"},
+        status=200,
+        match=[responses.matchers.query_param_matcher({})],
+    )
+
+    mgr = M(gl)
+    obj = mgr.create({"foo": "bar"})
+    assert isinstance(obj, FakeObject)
+    assert obj.id == 42
+    assert obj.foo == "bar"
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_create_mixin_custom_path(gl):
+    class M(CreateMixin, FakeManager):
+        _create_attrs = gl_types.RequiredOptional(
+            required=("foo",), optional=("bar", "baz")
+        )
+        _update_attrs = gl_types.RequiredOptional(required=("foo",), optional=("bam",))
+
+    url = "http://localhost/api/v4/others"
+    responses.add(
+        method=responses.POST,
+        url=url,
+        json={"id": 42, "foo": "bar"},
+        status=200,
+        match=[responses.matchers.query_param_matcher({})],
+    )
+
+    mgr = M(gl)
+    obj = mgr.create({"foo": "bar"}, path="/others")
+    assert isinstance(obj, FakeObject)
+    assert obj.id == 42
+    assert obj.foo == "bar"
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_create_mixin_with_attributes(gl):
+    class M(CreateMixin, FakeManager):
+        _types = {"my_array": gl_types.ArrayAttribute}
+
+    url = "http://localhost/api/v4/tests"
+    responses.add(
+        method=responses.POST,
+        headers={},
+        url=url,
+        json={},
+        status=200,
+        match=[responses.matchers.json_params_matcher({"my_array": [1, 2, 3]})],
+    )
+
+    mgr = M(gl)
+    mgr.create({"my_array": [1, 2, 3]})
+
+
+def test_update_mixin_missing_attrs(gl):
+    class M(UpdateMixin, FakeManager):
+        _update_attrs = gl_types.RequiredOptional(
+            required=("foo",), optional=("bar", "baz")
+        )
+
+    mgr = M(gl)
+    data = {"foo": "bar", "baz": "blah"}
+    mgr._update_attrs.validate_attrs(data=data)
+
+    data = {"baz": "blah"}
+    with pytest.raises(AttributeError) as error:
+        mgr._update_attrs.validate_attrs(data=data)
+    assert "foo" in str(error.value)
+
+
+@responses.activate
+def test_update_mixin(gl):
+    class M(UpdateMixin, FakeManager):
+        _create_attrs = gl_types.RequiredOptional(
+            required=("foo",), optional=("bar", "baz")
+        )
+        _update_attrs = gl_types.RequiredOptional(required=("foo",), optional=("bam",))
+
+    url = "http://localhost/api/v4/tests/42"
+    responses.add(
+        method=responses.PUT,
+        url=url,
+        json={"id": 42, "foo": "baz"},
+        status=200,
+        match=[responses.matchers.query_param_matcher({})],
+    )
+
+    mgr = M(gl)
+    server_data = mgr.update(42, {"foo": "baz"})
+    assert isinstance(server_data, dict)
+    assert server_data["id"] == 42
+    assert server_data["foo"] == "baz"
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_update_mixin_uses_post(gl):
+    class M(UpdateMixin, FakeManager):
+        _update_method = UpdateMethod.POST
+
+    url = "http://localhost/api/v4/tests/1"
+    responses.add(
+        method=responses.POST,
+        url=url,
+        json={},
+        status=200,
+        match=[responses.matchers.query_param_matcher({})],
+    )
+
+    mgr = M(gl)
+    mgr.update(1, {})
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_update_mixin_no_id(gl):
+    class M(UpdateMixin, FakeManager):
+        _create_attrs = gl_types.RequiredOptional(
+            required=("foo",), optional=("bar", "baz")
+        )
+        _update_attrs = gl_types.RequiredOptional(required=("foo",), optional=("bam",))
+
+    url = "http://localhost/api/v4/tests"
+    responses.add(
+        method=responses.PUT,
+        url=url,
+        json={"foo": "baz"},
+        status=200,
+        match=[responses.matchers.query_param_matcher({})],
+    )
+
+    mgr = M(gl)
+    server_data = mgr.update(new_data={"foo": "baz"})
+    assert isinstance(server_data, dict)
+    assert server_data["foo"] == "baz"
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_delete_mixin(gl):
+    class M(DeleteMixin, FakeManager):
+        pass
+
+    url = "http://localhost/api/v4/tests/42"
+    responses.add(
+        method=responses.DELETE,
+        url=url,
+        json="",
+        status=200,
+        match=[responses.matchers.query_param_matcher({})],
+    )
+
+    mgr = M(gl)
+    mgr.delete(42)
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_save_mixin(gl):
+    class M(UpdateMixin, FakeManager):
+        pass
+
+    class TestClass(SaveMixin, base.RESTObject):
+        pass
+
+    url = "http://localhost/api/v4/tests/42"
+    responses.add(
+        method=responses.PUT,
+        url=url,
+        json={"id": 42, "foo": "baz"},
+        status=200,
+        match=[responses.matchers.query_param_matcher({})],
+    )
+
+    mgr = M(gl)
+    obj = TestClass(mgr, {"id": 42, "foo": "bar"})
+    obj.foo = "baz"
+    obj.save()
+    assert obj._attrs["foo"] == "baz"
+    assert obj._updated_attrs == {}
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_save_mixin_without_new_data(gl):
+    class M(UpdateMixin, FakeManager):
+        pass
+
+    class TestClass(SaveMixin, base.RESTObject):
+        pass
+
+    url = "http://localhost/api/v4/tests/1"
+    responses.add(method=responses.PUT, url=url)
+
+    mgr = M(gl)
+    obj = TestClass(mgr, {"id": 1, "foo": "bar"})
+    obj.save()
+
+    assert obj._attrs["foo"] == "bar"
+    assert responses.assert_call_count(url, 0) is True
+
+
+@responses.activate
+def test_set_mixin(gl):
+    class M(SetMixin, FakeManager):
+        pass
+
+    url = "http://localhost/api/v4/tests/foo"
+    responses.add(
+        method=responses.PUT,
+        url=url,
+        json={"key": "foo", "value": "bar"},
+        status=200,
+        match=[responses.matchers.query_param_matcher({})],
+    )
+
+    mgr = M(gl)
+    obj = mgr.set("foo", "bar")
+    assert isinstance(obj, FakeObject)
+    assert obj.key == "foo"
+    assert obj.value == "bar"
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_upload_mixin_with_filepath_and_filedata(gl):
+    class TestClass(UploadMixin, FakeObject):
+        _upload_path = "/tests/{id}/uploads"
+
+    url = "http://localhost/api/v4/tests/42/uploads"
+    responses.add(
+        method=responses.POST,
+        url=url,
+        json={"id": 42, "file_name": "test.txt", "file_content": "testing contents"},
+        status=200,
+        match=[responses.matchers.query_param_matcher({})],
+    )
+
+    mgr = FakeManager(gl)
+    obj = TestClass(mgr, {"id": 42})
+    with pytest.raises(
+        GitlabUploadError, match="File contents and file path specified"
+    ):
+        obj.upload("test.txt", "testing contents", "/home/test.txt")
+
+
+@responses.activate
+def test_upload_mixin_without_filepath_nor_filedata(gl):
+    class TestClass(UploadMixin, FakeObject):
+        _upload_path = "/tests/{id}/uploads"
+
+    url = "http://localhost/api/v4/tests/42/uploads"
+    responses.add(
+        method=responses.POST,
+        url=url,
+        json={"id": 42, "file_name": "test.txt", "file_content": "testing contents"},
+        status=200,
+        match=[responses.matchers.query_param_matcher({})],
+    )
+
+    mgr = FakeManager(gl)
+    obj = TestClass(mgr, {"id": 42})
+    with pytest.raises(GitlabUploadError, match="No file contents or path specified"):
+        obj.upload("test.txt")
+
+
+@responses.activate
+def test_upload_mixin_with_filedata(gl):
+    class TestClass(UploadMixin, FakeObject):
+        _upload_path = "/tests/{id}/uploads"
+
+    url = "http://localhost/api/v4/tests/42/uploads"
+    responses.add(
+        method=responses.POST,
+        url=url,
+        json={"id": 42, "file_name": "test.txt", "file_content": "testing contents"},
+        status=200,
+        match=[responses.matchers.query_param_matcher({})],
+    )
+
+    mgr = FakeManager(gl)
+    obj = TestClass(mgr, {"id": 42})
+    res_only_data = obj.upload("test.txt", "testing contents")
+    assert obj._get_upload_path() == "/tests/42/uploads"
+    assert isinstance(res_only_data, dict)
+    assert res_only_data["file_name"] == "test.txt"
+    assert res_only_data["file_content"] == "testing contents"
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_upload_mixin_with_filepath(gl):
+    class TestClass(UploadMixin, FakeObject):
+        _upload_path = "/tests/{id}/uploads"
+
+    url = "http://localhost/api/v4/tests/42/uploads"
+    responses.add(
+        method=responses.POST,
+        url=url,
+        json={"id": 42, "file_name": "test.txt", "file_content": "testing contents"},
+        status=200,
+        match=[responses.matchers.query_param_matcher({})],
+    )
+
+    mgr = FakeManager(gl)
+    obj = TestClass(mgr, {"id": 42})
+    with patch("builtins.open", mock_open(read_data="raw\nfile\ndata")):
+        res_only_path = obj.upload("test.txt", None, "/filepath")
+    assert obj._get_upload_path() == "/tests/42/uploads"
+    assert isinstance(res_only_path, dict)
+    assert res_only_path["file_name"] == "test.txt"
+    assert res_only_path["file_content"] == "testing contents"
+    assert responses.assert_call_count(url, 1) is True
diff --git a/tests/unit/mixins/test_object_mixins_attributes.py b/tests/unit/mixins/test_object_mixins_attributes.py
new file mode 100644
index 000000000..99f301933
--- /dev/null
+++ b/tests/unit/mixins/test_object_mixins_attributes.py
@@ -0,0 +1,63 @@
+from unittest.mock import MagicMock
+
+from gitlab.mixins import (
+    AccessRequestMixin,
+    SetMixin,
+    SubscribableMixin,
+    TimeTrackingMixin,
+    TodoMixin,
+    UserAgentDetailMixin,
+)
+
+
+def test_access_request_mixin():
+    class TestClass(AccessRequestMixin):
+        pass
+
+    obj = TestClass()
+    assert hasattr(obj, "approve")
+
+
+def test_subscribable_mixin():
+    class TestClass(SubscribableMixin):
+        pass
+
+    obj = TestClass()
+    assert hasattr(obj, "subscribe")
+    assert hasattr(obj, "unsubscribe")
+
+
+def test_todo_mixin():
+    class TestClass(TodoMixin):
+        pass
+
+    obj = TestClass()
+    assert hasattr(obj, "todo")
+
+
+def test_time_tracking_mixin():
+    class TestClass(TimeTrackingMixin):
+        pass
+
+    obj = TestClass()
+    assert hasattr(obj, "time_stats")
+    assert hasattr(obj, "time_estimate")
+    assert hasattr(obj, "reset_time_estimate")
+    assert hasattr(obj, "add_spent_time")
+    assert hasattr(obj, "reset_spent_time")
+
+
+def test_set_mixin():
+    class TestClass(SetMixin):
+        _obj_cls = object
+        _path = "/test"
+
+    obj = TestClass(MagicMock())
+    assert hasattr(obj, "set")
+
+
+def test_user_agent_detail_mixin():
+    class TestClass(UserAgentDetailMixin): ...
+
+    obj = TestClass()
+    assert hasattr(obj, "user_agent_detail")
diff --git a/tests/unit/objects/__init__.py b/tests/unit/objects/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/objects/conftest.py b/tests/unit/objects/conftest.py
new file mode 100644
index 000000000..915c9dd3d
--- /dev/null
+++ b/tests/unit/objects/conftest.py
@@ -0,0 +1,80 @@
+"""Common mocks for resources in gitlab.v4.objects"""
+
+import re
+
+import pytest
+import responses
+
+
+@pytest.fixture
+def binary_content():
+    return b"binary content"
+
+
+@pytest.fixture
+def accepted_content():
+    return {"message": "202 Accepted"}
+
+
+@pytest.fixture
+def created_content():
+    return {"message": "201 Created"}
+
+
+@pytest.fixture
+def token_content():
+    return {
+        "user_id": 141,
+        "scopes": ["api"],
+        "name": "token",
+        "expires_at": "2021-01-31",
+        "id": 42,
+        "active": True,
+        "created_at": "2021-01-20T22:11:48.151Z",
+        "revoked": False,
+        "token": "s3cr3t",
+    }
+
+
+@pytest.fixture
+def resp_export(accepted_content, binary_content):
+    """Common fixture for group and project exports."""
+    export_status_content = {
+        "id": 1,
+        "description": "Itaque perspiciatis minima aspernatur",
+        "name": "Gitlab Test",
+        "name_with_namespace": "Gitlab Org / Gitlab Test",
+        "path": "gitlab-test",
+        "path_with_namespace": "gitlab-org/gitlab-test",
+        "created_at": "2017-08-29T04:36:44.383Z",
+        "export_status": "finished",
+        "_links": {
+            "api_url": "https://gitlab.test/api/v4/projects/1/export/download",
+            "web_url": "https://gitlab.test/gitlab-test/download_export",
+        },
+    }
+
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.POST,
+            url=re.compile(r".*/api/v4/(groups|projects)/1/export"),
+            json=accepted_content,
+            content_type="application/json",
+            status=202,
+        )
+        rsps.add(
+            method=responses.GET,
+            url=re.compile(r".*/api/v4/(groups|projects)/1/export/download"),
+            body=binary_content,
+            content_type="application/octet-stream",
+            status=200,
+        )
+        # Currently only project export supports status checks
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/export",
+            json=export_status_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
diff --git a/tests/unit/objects/test_appearance.py b/tests/unit/objects/test_appearance.py
new file mode 100644
index 000000000..0de65244d
--- /dev/null
+++ b/tests/unit/objects/test_appearance.py
@@ -0,0 +1,65 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/appearance.html
+"""
+
+import pytest
+import responses
+
+title = "GitLab Test Instance"
+description = "gitlab-test.example.com"
+new_title = "new-title"
+new_description = "new-description"
+
+
+@pytest.fixture
+def resp_application_appearance():
+    content = {
+        "title": title,
+        "description": description,
+        "logo": "/uploads/-/system/appearance/logo/1/logo.png",
+        "header_logo": "/uploads/-/system/appearance/header_logo/1/header.png",
+        "favicon": "/uploads/-/system/appearance/favicon/1/favicon.png",
+        "new_project_guidelines": "Please read the FAQs for help.",
+        "header_message": "",
+        "footer_message": "",
+        "message_background_color": "#e75e40",
+        "message_font_color": "#ffffff",
+        "email_header_and_footer_enabled": False,
+    }
+
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/application/appearance",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+
+        updated_content = dict(content)
+        updated_content["title"] = new_title
+        updated_content["description"] = new_description
+
+        rsps.add(
+            method=responses.PUT,
+            url="http://localhost/api/v4/application/appearance",
+            json=updated_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_get_update_appearance(gl, resp_application_appearance):
+    appearance = gl.appearance.get()
+    assert appearance.title == title
+    assert appearance.description == description
+    appearance.title = new_title
+    appearance.description = new_description
+    appearance.save()
+    assert appearance.title == new_title
+    assert appearance.description == new_description
+
+
+def test_update_appearance(gl, resp_application_appearance):
+    gl.appearance.update(title=new_title, description=new_description)
diff --git a/tests/unit/objects/test_applications.py b/tests/unit/objects/test_applications.py
new file mode 100644
index 000000000..61de0199f
--- /dev/null
+++ b/tests/unit/objects/test_applications.py
@@ -0,0 +1,44 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/applications.html
+"""
+
+import pytest
+import responses
+
+title = "GitLab Test Instance"
+description = "gitlab-test.example.com"
+new_title = "new-title"
+new_description = "new-description"
+
+
+@pytest.fixture
+def resp_application_create():
+    content = {
+        "name": "test_app",
+        "redirect_uri": "http://localhost:8080",
+        "scopes": ["api", "email"],
+    }
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/applications",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_create_application(gl, resp_application_create):
+    application = gl.applications.create(
+        {
+            "name": "test_app",
+            "redirect_uri": "http://localhost:8080",
+            "scopes": ["api", "email"],
+            "confidential": False,
+        }
+    )
+    assert application.name == "test_app"
+    assert application.redirect_uri == "http://localhost:8080"
+    assert application.scopes == ["api", "email"]
diff --git a/tests/unit/objects/test_audit_events.py b/tests/unit/objects/test_audit_events.py
new file mode 100644
index 000000000..aba778bb7
--- /dev/null
+++ b/tests/unit/objects/test_audit_events.py
@@ -0,0 +1,109 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/audit_events.html#project-audit-events
+"""
+
+import re
+
+import pytest
+import responses
+
+from gitlab.v4.objects.audit_events import (
+    AuditEvent,
+    GroupAuditEvent,
+    ProjectAuditEvent,
+)
+
+id = 5
+
+audit_events_content = {
+    "id": 5,
+    "author_id": 1,
+    "entity_id": 7,
+    "entity_type": "Project",
+    "details": {
+        "change": "prevent merge request approval from reviewers",
+        "from": "",
+        "to": "true",
+        "author_name": "Administrator",
+        "target_id": 7,
+        "target_type": "Project",
+        "target_details": "twitter/typeahead-js",
+        "ip_address": "127.0.0.1",
+        "entity_path": "twitter/typeahead-js",
+    },
+    "created_at": "2020-05-26T22:55:04.230Z",
+}
+
+audit_events_url = re.compile(
+    r"http://localhost/api/v4/((groups|projects)/1/)?audit_events"
+)
+
+audit_events_url_id = re.compile(
+    rf"http://localhost/api/v4/((groups|projects)/1/)?audit_events/{id}"
+)
+
+
+@pytest.fixture
+def resp_list_audit_events():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=audit_events_url,
+            json=[audit_events_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_audit_event():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=audit_events_url_id,
+            json=audit_events_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_list_instance_audit_events(gl, resp_list_audit_events):
+    audit_events = gl.audit_events.list()
+    assert isinstance(audit_events, list)
+    assert isinstance(audit_events[0], AuditEvent)
+    assert audit_events[0].id == id
+
+
+def test_get_instance_audit_events(gl, resp_get_audit_event):
+    audit_event = gl.audit_events.get(id)
+    assert isinstance(audit_event, AuditEvent)
+    assert audit_event.id == id
+
+
+def test_list_group_audit_events(group, resp_list_audit_events):
+    audit_events = group.audit_events.list()
+    assert isinstance(audit_events, list)
+    assert isinstance(audit_events[0], GroupAuditEvent)
+    assert audit_events[0].id == id
+
+
+def test_get_group_audit_events(group, resp_get_audit_event):
+    audit_event = group.audit_events.get(id)
+    assert isinstance(audit_event, GroupAuditEvent)
+    assert audit_event.id == id
+
+
+def test_list_project_audit_events(project, resp_list_audit_events):
+    audit_events = project.audit_events.list()
+    assert isinstance(audit_events, list)
+    assert isinstance(audit_events[0], ProjectAuditEvent)
+    assert audit_events[0].id == id
+
+
+def test_get_project_audit_events(project, resp_get_audit_event):
+    audit_event = project.audit_events.get(id)
+    assert isinstance(audit_event, ProjectAuditEvent)
+    assert audit_event.id == id
diff --git a/tests/unit/objects/test_badges.py b/tests/unit/objects/test_badges.py
new file mode 100644
index 000000000..233a5f097
--- /dev/null
+++ b/tests/unit/objects/test_badges.py
@@ -0,0 +1,200 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/project_badges.html
+GitLab API: https://docs.gitlab.com/ee/api/group_badges.html
+"""
+
+import re
+
+import pytest
+import responses
+
+from gitlab.v4.objects import GroupBadge, ProjectBadge
+
+link_url = (
+    "http://example.com/ci_status.svg?project=example-org/example-project&ref=main"
+)
+image_url = "https://example.io/my/badge"
+
+rendered_link_url = (
+    "http://example.com/ci_status.svg?project=example-org/example-project&ref=main"
+)
+rendered_image_url = "https://example.io/my/badge"
+
+new_badge = {"link_url": link_url, "image_url": image_url}
+
+badge_content = {
+    "name": "Coverage",
+    "id": 1,
+    "link_url": link_url,
+    "image_url": image_url,
+    "rendered_link_url": rendered_image_url,
+    "rendered_image_url": rendered_image_url,
+}
+
+preview_badge_content = {
+    "link_url": link_url,
+    "image_url": image_url,
+    "rendered_link_url": rendered_link_url,
+    "rendered_image_url": rendered_image_url,
+}
+
+
+@pytest.fixture()
+def resp_get_badge():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=re.compile(r"http://localhost/api/v4/(projects|groups)/1/badges/1"),
+            json=badge_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture()
+def resp_list_badges():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=re.compile(r"http://localhost/api/v4/(projects|groups)/1/badges"),
+            json=[badge_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture()
+def resp_create_badge():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url=re.compile(r"http://localhost/api/v4/(projects|groups)/1/badges"),
+            json=badge_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture()
+def resp_update_badge():
+    updated_content = dict(badge_content)
+    updated_content["link_url"] = "http://link_url"
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.PUT,
+            url=re.compile(r"http://localhost/api/v4/(projects|groups)/1/badges/1"),
+            json=updated_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture()
+def resp_delete_badge():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.DELETE,
+            url=re.compile(r"http://localhost/api/v4/(projects|groups)/1/badges/1"),
+            status=204,
+        )
+        yield rsps
+
+
+@pytest.fixture()
+def resp_preview_badge():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=re.compile(
+                r"http://localhost/api/v4/(projects|groups)/1/badges/render"
+            ),
+            json=preview_badge_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_list_project_badges(project, resp_list_badges):
+    badges = project.badges.list()
+    assert isinstance(badges, list)
+    assert isinstance(badges[0], ProjectBadge)
+
+
+def test_list_group_badges(group, resp_list_badges):
+    badges = group.badges.list()
+    assert isinstance(badges, list)
+    assert isinstance(badges[0], GroupBadge)
+
+
+def test_get_project_badge(project, resp_get_badge):
+    badge = project.badges.get(1)
+    assert isinstance(badge, ProjectBadge)
+    assert badge.name == "Coverage"
+    assert badge.id == 1
+
+
+def test_get_group_badge(group, resp_get_badge):
+    badge = group.badges.get(1)
+    assert isinstance(badge, GroupBadge)
+    assert badge.name == "Coverage"
+    assert badge.id == 1
+
+
+def test_delete_project_badge(project, resp_delete_badge):
+    badge = project.badges.get(1, lazy=True)
+    badge.delete()
+
+
+def test_delete_group_badge(group, resp_delete_badge):
+    badge = group.badges.get(1, lazy=True)
+    badge.delete()
+
+
+def test_create_project_badge(project, resp_create_badge):
+    badge = project.badges.create(new_badge)
+    assert isinstance(badge, ProjectBadge)
+    assert badge.image_url == image_url
+
+
+def test_create_group_badge(group, resp_create_badge):
+    badge = group.badges.create(new_badge)
+    assert isinstance(badge, GroupBadge)
+    assert badge.image_url == image_url
+
+
+def test_preview_project_badge(project, resp_preview_badge):
+    output = project.badges.render(link_url=link_url, image_url=image_url)
+    assert isinstance(output, dict)
+    assert "rendered_link_url" in output
+    assert "rendered_image_url" in output
+    assert output["link_url"] == output["rendered_link_url"]
+    assert output["image_url"] == output["rendered_image_url"]
+
+
+def test_preview_group_badge(group, resp_preview_badge):
+    output = group.badges.render(link_url=link_url, image_url=image_url)
+    assert isinstance(output, dict)
+    assert "rendered_link_url" in output
+    assert "rendered_image_url" in output
+    assert output["link_url"] == output["rendered_link_url"]
+    assert output["image_url"] == output["rendered_image_url"]
+
+
+def test_update_project_badge(project, resp_update_badge):
+    badge = project.badges.get(1, lazy=True)
+    badge.link_url = "http://link_url"
+    badge.save()
+    assert badge.link_url == "http://link_url"
+
+
+def test_update_group_badge(group, resp_update_badge):
+    badge = group.badges.get(1, lazy=True)
+    badge.link_url = "http://link_url"
+    badge.save()
+    assert badge.link_url == "http://link_url"
diff --git a/tests/unit/objects/test_bridges.py b/tests/unit/objects/test_bridges.py
new file mode 100644
index 000000000..892e942a0
--- /dev/null
+++ b/tests/unit/objects/test_bridges.py
@@ -0,0 +1,110 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/jobs.html#list-pipeline-bridges
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import ProjectPipelineBridge
+
+
+@pytest.fixture
+def resp_list_bridges():
+    export_bridges_content = {
+        "commit": {
+            "author_email": "admin@example.com",
+            "author_name": "Administrator",
+            "created_at": "2015-12-24T16:51:14.000+01:00",
+            "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd",
+            "message": "Test the CI integration.",
+            "short_id": "0ff3ae19",
+            "title": "Test the CI integration.",
+        },
+        "allow_failure": False,
+        "created_at": "2015-12-24T15:51:21.802Z",
+        "started_at": "2015-12-24T17:54:27.722Z",
+        "finished_at": "2015-12-24T17:58:27.895Z",
+        "duration": 240,
+        "id": 7,
+        "name": "teaspoon",
+        "pipeline": {
+            "id": 6,
+            "ref": "main",
+            "sha": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd",
+            "status": "pending",
+            "created_at": "2015-12-24T15:50:16.123Z",
+            "updated_at": "2015-12-24T18:00:44.432Z",
+            "web_url": "https://example.com/foo/bar/pipelines/6",
+        },
+        "ref": "main",
+        "stage": "test",
+        "status": "pending",
+        "tag": False,
+        "web_url": "https://example.com/foo/bar/-/jobs/7",
+        "user": {
+            "id": 1,
+            "name": "Administrator",
+            "username": "root",
+            "state": "active",
+            "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+            "web_url": "http://gitlab.dev/root",
+            "created_at": "2015-12-21T13:14:24.077Z",
+            "public_email": "",
+            "skype": "",
+            "linkedin": "",
+            "twitter": "",
+            "website_url": "",
+            "organization": "",
+        },
+        "downstream_pipeline": {
+            "id": 5,
+            "sha": "f62a4b2fb89754372a346f24659212eb8da13601",
+            "ref": "main",
+            "status": "pending",
+            "created_at": "2015-12-24T17:54:27.722Z",
+            "updated_at": "2015-12-24T17:58:27.896Z",
+            "web_url": "https://example.com/diaspora/diaspora-client/pipelines/5",
+        },
+    }
+
+    export_pipelines_content = [
+        {
+            "id": 6,
+            "status": "pending",
+            "ref": "new-pipeline",
+            "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+            "web_url": "https://example.com/foo/bar/pipelines/47",
+            "created_at": "2016-08-11T11:28:34.085Z",
+            "updated_at": "2016-08-11T11:32:35.169Z",
+        }
+    ]
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/pipelines/6/bridges",
+            json=[export_bridges_content],
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/pipelines",
+            json=export_pipelines_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_list_projects_pipelines_bridges(project, resp_list_bridges):
+    pipeline = project.pipelines.list()[0]
+    bridges = pipeline.bridges.list()
+
+    assert isinstance(bridges, list)
+    assert isinstance(bridges[0], ProjectPipelineBridge)
+    assert bridges[0].downstream_pipeline["id"] == 5
+    assert (
+        bridges[0].downstream_pipeline["sha"]
+        == "f62a4b2fb89754372a346f24659212eb8da13601"
+    )
diff --git a/tests/unit/objects/test_bulk_imports.py b/tests/unit/objects/test_bulk_imports.py
new file mode 100644
index 000000000..a8001806e
--- /dev/null
+++ b/tests/unit/objects/test_bulk_imports.py
@@ -0,0 +1,153 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/bulk_imports.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import BulkImport, BulkImportAllEntity, BulkImportEntity
+
+migration_content = {
+    "id": 1,
+    "status": "finished",
+    "source_type": "gitlab",
+    "created_at": "2021-06-18T09:45:55.358Z",
+    "updated_at": "2021-06-18T09:46:27.003Z",
+}
+entity_content = {
+    "id": 1,
+    "bulk_import_id": 1,
+    "status": "finished",
+    "source_full_path": "source_group",
+    "destination_slug": "destination_slug",
+    "destination_namespace": "destination_path",
+    "parent_id": None,
+    "namespace_id": 1,
+    "project_id": None,
+    "created_at": "2021-06-18T09:47:37.390Z",
+    "updated_at": "2021-06-18T09:47:51.867Z",
+    "failures": [],
+}
+
+
+@pytest.fixture
+def resp_create_bulk_import():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/bulk_imports",
+            json=migration_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_bulk_imports():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/bulk_imports",
+            json=[migration_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_bulk_import():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/bulk_imports/1",
+            json=migration_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_all_bulk_import_entities():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/bulk_imports/entities",
+            json=[entity_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_bulk_import_entities():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/bulk_imports/1/entities",
+            json=[entity_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_bulk_import_entity():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/bulk_imports/1/entities/1",
+            json=entity_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_create_bulk_import(gl, resp_create_bulk_import):
+    configuration = {"url": gl.url, "access_token": "test-token"}
+    migration_entity = {
+        "source_full_path": "source",
+        "source_type": "group_entity",
+        "destination_slug": "destination",
+        "destination_namespace": "destination",
+    }
+    migration = gl.bulk_imports.create(
+        {"configuration": configuration, "entities": [migration_entity]}
+    )
+    assert isinstance(migration, BulkImport)
+    assert migration.status == "finished"
+
+
+def test_list_bulk_imports(gl, resp_list_bulk_imports):
+    migrations = gl.bulk_imports.list()
+    assert isinstance(migrations[0], BulkImport)
+    assert migrations[0].status == "finished"
+
+
+def test_get_bulk_import(gl, resp_get_bulk_import):
+    migration = gl.bulk_imports.get(1)
+    assert isinstance(migration, BulkImport)
+    assert migration.status == "finished"
+
+
+def test_list_all_bulk_import_entities(gl, resp_list_all_bulk_import_entities):
+    entities = gl.bulk_import_entities.list()
+    assert isinstance(entities[0], BulkImportAllEntity)
+    assert entities[0].bulk_import_id == 1
+
+
+def test_list_bulk_import_entities(gl, migration, resp_list_bulk_import_entities):
+    entities = migration.entities.list()
+    assert isinstance(entities[0], BulkImportEntity)
+    assert entities[0].bulk_import_id == 1
+
+
+def test_get_bulk_import_entity(gl, migration, resp_get_bulk_import_entity):
+    entity = migration.entities.get(1)
+    assert isinstance(entity, BulkImportEntity)
+    assert entity.bulk_import_id == 1
diff --git a/tests/unit/objects/test_ci_lint.py b/tests/unit/objects/test_ci_lint.py
new file mode 100644
index 000000000..76281f1e2
--- /dev/null
+++ b/tests/unit/objects/test_ci_lint.py
@@ -0,0 +1,99 @@
+import pytest
+import responses
+
+from gitlab import exceptions
+
+ci_lint_create_content = {"status": "valid", "errors": [], "warnings": []}
+ci_lint_create_invalid_content = {
+    "status": "invalid",
+    "errors": ["invalid format"],
+    "warnings": [],
+}
+
+
+project_ci_lint_content = {
+    "valid": True,
+    "merged_yaml": "---\n:test_job:\n  :script: echo 1\n",
+    "errors": [],
+    "warnings": [],
+}
+
+
+@pytest.fixture
+def resp_create_ci_lint():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/ci/lint",
+            json=ci_lint_create_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_create_ci_lint_invalid():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/ci/lint",
+            json=ci_lint_create_invalid_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_project_ci_lint():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/ci/lint",
+            json=project_ci_lint_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_create_project_ci_lint():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/ci/lint",
+            json=project_ci_lint_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_ci_lint_create(gl, resp_create_ci_lint, valid_gitlab_ci_yml):
+    lint_result = gl.ci_lint.create({"content": valid_gitlab_ci_yml})
+    assert lint_result.status == "valid"
+
+
+def test_ci_lint_validate(gl, resp_create_ci_lint, valid_gitlab_ci_yml):
+    gl.ci_lint.validate({"content": valid_gitlab_ci_yml})
+
+
+def test_ci_lint_validate_invalid_raises(
+    gl, resp_create_ci_lint_invalid, invalid_gitlab_ci_yml
+):
+    with pytest.raises(exceptions.GitlabCiLintError, match="invalid format"):
+        gl.ci_lint.validate({"content": invalid_gitlab_ci_yml})
+
+
+def test_project_ci_lint_get(project, resp_get_project_ci_lint):
+    lint_result = project.ci_lint.get()
+    assert lint_result.valid is True
+
+
+def test_project_ci_lint_create(
+    project, resp_create_project_ci_lint, valid_gitlab_ci_yml
+):
+    lint_result = project.ci_lint.create({"content": valid_gitlab_ci_yml})
+    assert lint_result.valid is True
diff --git a/tests/unit/objects/test_cluster_agents.py b/tests/unit/objects/test_cluster_agents.py
new file mode 100644
index 000000000..c17f3aa99
--- /dev/null
+++ b/tests/unit/objects/test_cluster_agents.py
@@ -0,0 +1,97 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/cluster_agents.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import ProjectClusterAgent
+
+agent_content = {
+    "id": 1,
+    "name": "agent-1",
+    "config_project": {
+        "id": 20,
+        "description": "",
+        "name": "test",
+        "name_with_namespace": "Administrator / test",
+        "path": "test",
+        "path_with_namespace": "root/test",
+        "created_at": "2022-03-20T20:42:40.221Z",
+    },
+    "created_at": "2022-04-20T20:42:40.221Z",
+    "created_by_user_id": 42,
+}
+
+
+@pytest.fixture
+def resp_list_project_cluster_agents():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/cluster_agents",
+            json=[agent_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_project_cluster_agent():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/cluster_agents/1",
+            json=agent_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_create_project_cluster_agent():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/cluster_agents",
+            json=agent_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_delete_project_cluster_agent():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/projects/1/cluster_agents/1",
+            status=204,
+        )
+        yield rsps
+
+
+def test_list_project_cluster_agents(project, resp_list_project_cluster_agents):
+    agent = project.cluster_agents.list()[0]
+    assert isinstance(agent, ProjectClusterAgent)
+    assert agent.name == "agent-1"
+
+
+def test_get_project_cluster_agent(project, resp_get_project_cluster_agent):
+    agent = project.cluster_agents.get(1)
+    assert isinstance(agent, ProjectClusterAgent)
+    assert agent.name == "agent-1"
+
+
+def test_create_project_cluster_agent(project, resp_create_project_cluster_agent):
+    agent = project.cluster_agents.create({"name": "agent-1"})
+    assert isinstance(agent, ProjectClusterAgent)
+    assert agent.name == "agent-1"
+
+
+def test_delete_project_cluster_agent(project, resp_delete_project_cluster_agent):
+    agent = project.cluster_agents.get(1, lazy=True)
+    agent.delete()
diff --git a/tests/unit/objects/test_commits.py b/tests/unit/objects/test_commits.py
new file mode 100644
index 000000000..6673db575
--- /dev/null
+++ b/tests/unit/objects/test_commits.py
@@ -0,0 +1,155 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/commits.html
+"""
+
+import pytest
+import responses
+
+
+@pytest.fixture
+def resp_create_commit():
+    content = {
+        "id": "ed899a2f4b50b4370feeea94676502b42383c746",
+        "short_id": "ed899a2f",
+        "title": "Commit message",
+    }
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/repository/commits",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_commit():
+    get_content = {
+        "id": "6b2257eabcec3db1f59dafbd84935e3caea04235",
+        "short_id": "6b2257ea",
+        "title": "Initial commit",
+    }
+    revert_content = {
+        "id": "8b090c1b79a14f2bd9e8a738f717824ff53aebad",
+        "short_id": "8b090c1b",
+        "title": 'Revert "Initial commit"',
+    }
+    cherry_pick_content = {
+        "id": "8b090c1b79a14f2bd9e8a738f717824ff53aebad",
+        "short_id": "8b090c1b",
+        "title": "Initial commit",
+        "message": "Initial commit\n\n\n(cherry picked from commit 6b2257eabcec3db1f59dafbd84935e3caea04235)",
+    }
+
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/repository/commits/6b2257ea",
+            json=get_content,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/repository/commits/6b2257ea/revert",
+            json=revert_content,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/repository/commits/6b2257ea/cherry_pick",
+            json=cherry_pick_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_commit_gpg_signature():
+    content = {
+        "gpg_key_id": 1,
+        "gpg_key_primary_keyid": "8254AAB3FBD54AC9",
+        "gpg_key_user_name": "John Doe",
+        "gpg_key_user_email": "johndoe@example.com",
+        "verification_status": "verified",
+        "gpg_key_subkey_id": None,
+    }
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/repository/commits/6b2257ea/signature",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_commit_sequence():
+    content = {"count": 1}
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/repository/commits/6b2257ea/sequence",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_get_commit(project, resp_commit):
+    commit = project.commits.get("6b2257ea")
+    assert commit.short_id == "6b2257ea"
+    assert commit.title == "Initial commit"
+
+
+def test_create_commit(project, resp_create_commit):
+    data = {
+        "branch": "main",
+        "commit_message": "Commit message",
+        "actions": [{"action": "create", "file_path": "README", "content": ""}],
+    }
+    commit = project.commits.create(data)
+    assert commit.short_id == "ed899a2f"
+    assert commit.title == data["commit_message"]
+
+
+def test_cherry_pick_commit(project, resp_commit):
+    commit = project.commits.get("6b2257ea", lazy=True)
+    cherry_pick_commit = commit.cherry_pick(branch="main")
+
+    assert cherry_pick_commit["short_id"] == "8b090c1b"
+    assert cherry_pick_commit["title"] == "Initial commit"
+    assert (
+        cherry_pick_commit["message"]
+        == "Initial commit\n\n\n(cherry picked from commit 6b2257eabcec3db1f59dafbd84935e3caea04235)"
+    )
+
+
+def test_revert_commit(project, resp_commit):
+    commit = project.commits.get("6b2257ea", lazy=True)
+    revert_commit = commit.revert(branch="main")
+    assert revert_commit["short_id"] == "8b090c1b"
+    assert revert_commit["title"] == 'Revert "Initial commit"'
+
+
+def test_get_commit_gpg_signature(project, resp_get_commit_gpg_signature):
+    commit = project.commits.get("6b2257ea", lazy=True)
+    signature = commit.signature()
+    assert signature["gpg_key_primary_keyid"] == "8254AAB3FBD54AC9"
+    assert signature["verification_status"] == "verified"
+
+
+def test_get_commit_sequence(project, resp_get_commit_sequence):
+    commit = project.commits.get("6b2257ea", lazy=True)
+    sequence = commit.sequence()
+    assert sequence["count"] == 1
diff --git a/tests/unit/objects/test_deploy_tokens.py b/tests/unit/objects/test_deploy_tokens.py
new file mode 100644
index 000000000..e1ef4ed2d
--- /dev/null
+++ b/tests/unit/objects/test_deploy_tokens.py
@@ -0,0 +1,46 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/deploy_tokens.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import ProjectDeployToken
+
+create_content = {
+    "id": 1,
+    "name": "test_deploy_token",
+    "username": "custom-user",
+    "expires_at": "2022-01-01T00:00:00.000Z",
+    "token": "jMRvtPNxrn3crTAGukpZ",
+    "scopes": ["read_repository"],
+}
+
+
+@pytest.fixture
+def resp_deploy_token_create():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/deploy_tokens",
+            json=create_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_deploy_tokens(gl, resp_deploy_token_create):
+    deploy_token = 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"],
+        }
+    )
+    assert isinstance(deploy_token, ProjectDeployToken)
+    assert deploy_token.id == 1
+    assert deploy_token.expires_at == "2022-01-01T00:00:00.000Z"
+    assert deploy_token.username == "custom-user"
+    assert deploy_token.scopes == ["read_repository"]
diff --git a/tests/unit/objects/test_deployments.py b/tests/unit/objects/test_deployments.py
new file mode 100644
index 000000000..dda982bd4
--- /dev/null
+++ b/tests/unit/objects/test_deployments.py
@@ -0,0 +1,181 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/deployments.html
+"""
+
+import pytest
+import responses
+
+
+@pytest.fixture
+def resp_deployment_get():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/deployments/42",
+            json=response_get_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def deployment(project):
+    return project.deployments.get(42, lazy=True)
+
+
+@pytest.fixture
+def resp_deployment_create():
+    content = {"id": 42, "status": "success", "ref": "main"}
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/deployments",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+
+        updated_content = dict(content)
+        updated_content["status"] = "failed"
+
+        rsps.add(
+            method=responses.PUT,
+            url="http://localhost/api/v4/projects/1/deployments/42",
+            json=updated_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_deployment_approval():
+    content = {
+        "user": {
+            "id": 100,
+            "username": "security-user-1",
+            "name": "security user-1",
+            "state": "active",
+            "avatar_url": "https://www.gravatar.com/avatar/e130fcd3a1681f41a3de69d10841afa9?s=80&d=identicon",
+            "web_url": "http://localhost:3000/security-user-1",
+        },
+        "status": "approved",
+        "created_at": "2022-02-24T20:22:30.097Z",
+        "comment": "Looks good to me",
+    }
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/deployments/42/approval",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_deployment_get(project, resp_deployment_get):
+    deployment = project.deployments.get(42)
+    assert deployment.id == 42
+    assert deployment.iid == 2
+    assert deployment.status == "success"
+    assert deployment.ref == "main"
+
+
+def test_deployment_create(project, resp_deployment_create):
+    deployment = project.deployments.create(
+        {
+            "environment": "Test",
+            "sha": "1agf4gs",
+            "ref": "main",
+            "tag": False,
+            "status": "created",
+        }
+    )
+    assert deployment.id == 42
+    assert deployment.status == "success"
+    assert deployment.ref == "main"
+
+    deployment.status = "failed"
+    deployment.save()
+    assert deployment.status == "failed"
+
+
+def test_deployment_approval(deployment, resp_deployment_approval) -> None:
+    result = deployment.approval(status="approved")
+    assert result["status"] == "approved"
+    assert result["comment"] == "Looks good to me"
+
+
+response_get_content = {
+    "id": 42,
+    "iid": 2,
+    "ref": "main",
+    "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+    "created_at": "2016-08-11T11:32:35.444Z",
+    "updated_at": "2016-08-11T11:34:01.123Z",
+    "status": "success",
+    "user": {
+        "name": "Administrator",
+        "username": "root",
+        "id": 1,
+        "state": "active",
+        "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+        "web_url": "http://localhost:3000/root",
+    },
+    "environment": {
+        "id": 9,
+        "name": "production",
+        "external_url": "https://about.gitlab.com",
+    },
+    "deployable": {
+        "id": 664,
+        "status": "success",
+        "stage": "deploy",
+        "name": "deploy",
+        "ref": "main",
+        "tag": False,
+        "coverage": None,
+        "created_at": "2016-08-11T11:32:24.456Z",
+        "started_at": None,
+        "finished_at": "2016-08-11T11:32:35.145Z",
+        "user": {
+            "id": 1,
+            "name": "Administrator",
+            "username": "root",
+            "state": "active",
+            "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+            "web_url": "http://gitlab.dev/root",
+            "created_at": "2015-12-21T13:14:24.077Z",
+            "bio": None,
+            "location": None,
+            "skype": "",
+            "linkedin": "",
+            "twitter": "",
+            "website_url": "",
+            "organization": "",
+        },
+        "commit": {
+            "id": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+            "short_id": "a91957a8",
+            "title": "Merge branch 'rename-readme' into 'main'\r",
+            "author_name": "Administrator",
+            "author_email": "admin@example.com",
+            "created_at": "2016-08-11T13:28:26.000+02:00",
+            "message": "Merge branch 'rename-readme' into 'main'\r\n\r\nRename README\r\n\r\n\r\n\r\nSee merge request !2",
+        },
+        "pipeline": {
+            "created_at": "2016-08-11T07:43:52.143Z",
+            "id": 42,
+            "ref": "main",
+            "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+            "status": "success",
+            "updated_at": "2016-08-11T07:43:52.143Z",
+            "web_url": "http://gitlab.dev/root/project/pipelines/5",
+        },
+        "runner": None,
+    },
+}
diff --git a/tests/unit/objects/test_draft_notes.py b/tests/unit/objects/test_draft_notes.py
new file mode 100644
index 000000000..5f907b54f
--- /dev/null
+++ b/tests/unit/objects/test_draft_notes.py
@@ -0,0 +1,176 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/draft_notes.html
+"""
+
+from copy import deepcopy
+
+import pytest
+import responses
+
+from gitlab.v4.objects import ProjectMergeRequestDraftNote
+
+draft_note_content = {
+    "id": 1,
+    "author_id": 23,
+    "merge_request_id": 1,
+    "resolve_discussion": False,
+    "discussion_id": None,
+    "note": "Example title",
+    "commit_id": None,
+    "line_code": None,
+    "position": {
+        "base_sha": None,
+        "start_sha": None,
+        "head_sha": None,
+        "old_path": None,
+        "new_path": None,
+        "position_type": "text",
+        "old_line": None,
+        "new_line": None,
+        "line_range": None,
+    },
+}
+
+
+@pytest.fixture()
+def resp_list_merge_request_draft_notes():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/merge_requests/1/draft_notes",
+            json=[draft_note_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture()
+def resp_get_merge_request_draft_note():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/merge_requests/1/draft_notes/1",
+            json=draft_note_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture()
+def resp_create_merge_request_draft_note():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/merge_requests/1/draft_notes",
+            json=draft_note_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture()
+def resp_update_merge_request_draft_note():
+    updated_content = deepcopy(draft_note_content)
+    updated_content["note"] = "New title"
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.PUT,
+            url="http://localhost/api/v4/projects/1/merge_requests/1/draft_notes/1",
+            json=updated_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture()
+def resp_delete_merge_request_draft_note():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/projects/1/merge_requests/1/draft_notes/1",
+            json=draft_note_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture()
+def resp_publish_merge_request_draft_note():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.PUT,
+            url="http://localhost/api/v4/projects/1/merge_requests/1/draft_notes/1/publish",
+            status=204,
+        )
+        yield rsps
+
+
+@pytest.fixture()
+def resp_bulk_publish_merge_request_draft_notes():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/merge_requests/1/draft_notes/bulk_publish",
+            status=204,
+        )
+        yield rsps
+
+
+def test_list_merge_requests_draft_notes(
+    project_merge_request, resp_list_merge_request_draft_notes
+):
+    draft_notes = project_merge_request.draft_notes.list()
+    assert len(draft_notes) == 1
+    assert isinstance(draft_notes[0], ProjectMergeRequestDraftNote)
+    assert draft_notes[0].note == draft_note_content["note"]
+
+
+def test_get_merge_requests_draft_note(
+    project_merge_request, resp_get_merge_request_draft_note
+):
+    draft_note = project_merge_request.draft_notes.get(1)
+    assert isinstance(draft_note, ProjectMergeRequestDraftNote)
+    assert draft_note.note == draft_note_content["note"]
+
+
+def test_create_merge_requests_draft_note(
+    project_merge_request, resp_create_merge_request_draft_note
+):
+    draft_note = project_merge_request.draft_notes.create({"note": "Example title"})
+    assert isinstance(draft_note, ProjectMergeRequestDraftNote)
+    assert draft_note.note == draft_note_content["note"]
+
+
+def test_update_merge_requests_draft_note(
+    project_merge_request, resp_update_merge_request_draft_note
+):
+    draft_note = project_merge_request.draft_notes.get(1, lazy=True)
+    draft_note.note = "New title"
+    draft_note.save()
+    assert draft_note.note == "New title"
+
+
+def test_delete_merge_requests_draft_note(
+    project_merge_request, resp_delete_merge_request_draft_note
+):
+    draft_note = project_merge_request.draft_notes.get(1, lazy=True)
+    draft_note.delete()
+
+
+def test_publish_merge_requests_draft_note(
+    project_merge_request, resp_publish_merge_request_draft_note
+):
+    draft_note = project_merge_request.draft_notes.get(1, lazy=True)
+    draft_note.publish()
+
+
+def test_bulk_publish_merge_requests_draft_notes(
+    project_merge_request, resp_bulk_publish_merge_request_draft_notes
+):
+    project_merge_request.draft_notes.bulk_publish()
diff --git a/tests/unit/objects/test_environments.py b/tests/unit/objects/test_environments.py
new file mode 100644
index 000000000..ad4dead3a
--- /dev/null
+++ b/tests/unit/objects/test_environments.py
@@ -0,0 +1,53 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/environments.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import ProjectEnvironment, ProjectProtectedEnvironment
+
+
+@pytest.fixture
+def resp_get_environment():
+    content = {"name": "environment_name", "id": 1, "last_deployment": "sometime"}
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/environments/1",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_protected_environment():
+    content = {"name": "protected_environment_name", "last_deployment": "my birthday"}
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/protected_environments/2",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_project_environments(project, resp_get_environment):
+    environment = project.environments.get(1)
+    assert isinstance(environment, ProjectEnvironment)
+    assert environment.id == 1
+    assert environment.last_deployment == "sometime"
+    assert environment.name == "environment_name"
+
+
+def test_project_protected_environments(project, resp_get_protected_environment):
+    protected_environment = project.protected_environments.get(2)
+    assert isinstance(protected_environment, ProjectProtectedEnvironment)
+    assert protected_environment.last_deployment == "my birthday"
+    assert protected_environment.name == "protected_environment_name"
diff --git a/tests/unit/objects/test_group_access_tokens.py b/tests/unit/objects/test_group_access_tokens.py
new file mode 100644
index 000000000..c09ed8e12
--- /dev/null
+++ b/tests/unit/objects/test_group_access_tokens.py
@@ -0,0 +1,154 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/group_access_tokens.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import GroupAccessToken
+
+
+@pytest.fixture
+def resp_list_group_access_token(token_content):
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/groups/1/access_tokens",
+            json=[token_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_group_access_token(token_content):
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/groups/1/access_tokens/1",
+            json=token_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_create_group_access_token(token_content):
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/groups/1/access_tokens",
+            json=token_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_revoke_group_access_token():
+    content = [
+        {
+            "user_id": 141,
+            "scopes": ["api"],
+            "name": "token",
+            "expires_at": "2021-01-31",
+            "id": 42,
+            "active": True,
+            "created_at": "2021-01-20T22:11:48.151Z",
+            "revoked": False,
+        }
+    ]
+
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/groups/1/access_tokens/42",
+            status=204,
+        )
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/groups/1/access_tokens",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_rotate_group_access_token(token_content):
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/groups/1/access_tokens/1/rotate",
+            json=token_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_self_rotate_group_access_token(token_content):
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/groups/1/access_tokens/self/rotate",
+            json=token_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_list_group_access_tokens(gl, resp_list_group_access_token):
+    access_tokens = gl.groups.get(1, lazy=True).access_tokens.list()
+    assert len(access_tokens) == 1
+    assert access_tokens[0].revoked is False
+    assert access_tokens[0].name == "token"
+
+
+def test_get_group_access_token(group, resp_get_group_access_token):
+    access_token = group.access_tokens.get(1)
+    assert isinstance(access_token, GroupAccessToken)
+    assert access_token.revoked is False
+    assert access_token.name == "token"
+
+
+def test_create_group_access_token(gl, resp_create_group_access_token):
+    access_tokens = gl.groups.get(1, lazy=True).access_tokens.create(
+        {"name": "test", "scopes": ["api"]}
+    )
+    assert access_tokens.revoked is False
+    assert access_tokens.user_id == 141
+    assert access_tokens.expires_at == "2021-01-31"
+
+
+def test_revoke_group_access_token(
+    gl, resp_list_group_access_token, resp_revoke_group_access_token
+):
+    gl.groups.get(1, lazy=True).access_tokens.delete(42)
+    access_token = gl.groups.get(1, lazy=True).access_tokens.list()[0]
+    access_token.delete()
+
+
+def test_rotate_group_access_token(group, resp_rotate_group_access_token):
+    access_token = group.access_tokens.get(1, lazy=True)
+    access_token.rotate()
+    assert isinstance(access_token, GroupAccessToken)
+    assert access_token.token == "s3cr3t"
+
+
+def test_self_rotate_group_access_token(group, resp_self_rotate_group_access_token):
+    access_token = group.access_tokens.get(1, lazy=True)
+    access_token.rotate(self_rotate=True)
+    assert isinstance(access_token, GroupAccessToken)
+    assert access_token.token == "s3cr3t"
+
+    # Verify that the url contains "self"
+    rotation_calls = resp_self_rotate_group_access_token.calls
+    assert len(rotation_calls) == 1
+    assert "self/rotate" in rotation_calls[0].request.url
diff --git a/tests/unit/objects/test_group_merge_request_approvals.py b/tests/unit/objects/test_group_merge_request_approvals.py
new file mode 100644
index 000000000..e6cae1b38
--- /dev/null
+++ b/tests/unit/objects/test_group_merge_request_approvals.py
@@ -0,0 +1,253 @@
+"""
+Gitlab API: https://docs.gitlab.com/ee/api/merge_request_approvals.html
+"""
+
+import copy
+import json
+
+import pytest
+import responses
+
+approval_rule_id = 7
+approval_rule_name = "security"
+approvals_required = 3
+user_ids = [5, 50]
+group_ids = [5]
+
+new_approval_rule_name = "new approval rule"
+new_approval_rule_user_ids = user_ids
+new_approval_rule_approvals_required = 2
+
+updated_approval_rule_user_ids = [5]
+updated_approval_rule_approvals_required = 1
+
+
+@pytest.fixture
+def resp_group_approval_rules():
+    content = [
+        {
+            "id": approval_rule_id,
+            "name": approval_rule_name,
+            "rule_type": "regular",
+            "report_type": None,
+            "eligible_approvers": [
+                {
+                    "id": user_ids[0],
+                    "name": "John Doe",
+                    "username": "jdoe",
+                    "state": "active",
+                    "avatar_url": "https://www.gravatar.com/avatar/0?s=80&d=identicon",
+                    "web_url": "http://localhost/jdoe",
+                },
+                {
+                    "id": user_ids[1],
+                    "name": "Group Member 1",
+                    "username": "group_member_1",
+                    "state": "active",
+                    "avatar_url": "https://www.gravatar.com/avatar/0?s=80&d=identicon",
+                    "web_url": "http://localhost/group_member_1",
+                },
+            ],
+            "approvals_required": approvals_required,
+            "users": [
+                {
+                    "id": 5,
+                    "name": "John Doe",
+                    "username": "jdoe",
+                    "state": "active",
+                    "avatar_url": "https://www.gravatar.com/avatar/0?s=80&d=identicon",
+                    "web_url": "http://localhost/jdoe",
+                }
+            ],
+            "groups": [
+                {
+                    "id": 5,
+                    "name": "group1",
+                    "path": "group1",
+                    "description": "",
+                    "visibility": "public",
+                    "lfs_enabled": False,
+                    "avatar_url": None,
+                    "web_url": "http://localhost/groups/group1",
+                    "request_access_enabled": False,
+                    "full_name": "group1",
+                    "full_path": "group1",
+                    "parent_id": None,
+                    "ldap_cn": None,
+                    "ldap_access": None,
+                }
+            ],
+            "applies_to_all_protected_branches": False,
+            "protected_branches": [
+                {
+                    "id": 1,
+                    "name": "main",
+                    "push_access_levels": [
+                        {
+                            "access_level": 30,
+                            "access_level_description": "Developers + Maintainers",
+                        }
+                    ],
+                    "merge_access_levels": [
+                        {
+                            "access_level": 30,
+                            "access_level_description": "Developers + Maintainers",
+                        }
+                    ],
+                    "unprotect_access_levels": [
+                        {"access_level": 40, "access_level_description": "Maintainers"}
+                    ],
+                    "code_owner_approval_required": "false",
+                }
+            ],
+            "contains_hidden_groups": False,
+        }
+    ]
+
+    new_content = dict(content[0])
+    new_content["id"] = approval_rule_id + 1  # Assign a new ID for the new rule
+    new_content["name"] = new_approval_rule_name
+    new_content["approvals_required"] = new_approval_rule_approvals_required
+
+    updated_mr_ars_content = copy.deepcopy(content[0])
+    updated_mr_ars_content["name"] = new_approval_rule_name
+    updated_mr_ars_content["approvals_required"] = (
+        updated_approval_rule_approvals_required
+    )
+
+    list_request_options = {
+        "include_newly_created_rule": False,
+        "updated_first_rule": False,
+    }
+
+    def list_request_callback(request):
+        if request.method == "GET":
+            if list_request_options["include_newly_created_rule"]:
+                # Include newly created rule in the list response
+                return (
+                    200,
+                    {"Content-Type": "application/json"},
+                    json.dumps(content + [new_content]),
+                )
+            elif list_request_options["updated_first_rule"]:
+                # Include updated first rule in the list response
+                return (
+                    200,
+                    {"Content-Type": "application/json"},
+                    json.dumps([updated_mr_ars_content]),
+                )
+            else:
+                return (200, {"Content-Type": "application/json"}, json.dumps(content))
+        return (404, {}, "")
+
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        # Mock the API responses for listing all rules for group with ID 1
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/groups/1/approval_rules",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        # Mock the API responses for listing all rules for group with ID 1
+        # Use a callback to dynamically determine the response based on the request
+        rsps.add_callback(
+            method=responses.GET,
+            url="http://localhost/api/v4/groups/1/approval_rules",
+            callback=list_request_callback,
+            content_type="application/json",
+        )
+        # Mock the API responses for getting a specific rule for group with ID 1 and approvalrule with ID 7
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/groups/1/approval_rules/7",
+            json=content[0],
+            content_type="application/json",
+            status=200,
+        )
+        # Mock the API responses for creating a new rule for group with ID 1
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/groups/1/approval_rules",
+            json=new_content,
+            content_type="application/json",
+            status=200,
+        )
+        # Mock the API responses for updating a specific rule for group with ID 1 and approval rule with ID 7
+        rsps.add(
+            method=responses.PUT,
+            url="http://localhost/api/v4/groups/1/approval_rules/7",
+            json=updated_mr_ars_content,
+            content_type="application/json",
+            status=200,
+        )
+
+        yield rsps, list_request_options
+
+
+def test_list_group_mr_approval_rules(group, resp_group_approval_rules):
+    approval_rules = group.approval_rules.list()
+    assert len(approval_rules) == 1
+    assert approval_rules[0].name == approval_rule_name
+    assert approval_rules[0].id == approval_rule_id
+    assert (
+        repr(approval_rules[0])
+        == f"<GroupApprovalRule id:{approval_rule_id} name:{approval_rule_name}>"
+    )
+
+
+def test_save_group_mr_approval_rule(group, resp_group_approval_rules):
+    _, list_request_options = resp_group_approval_rules
+
+    # Before: existing approval rule
+    approval_rules = group.approval_rules.list()
+    assert len(approval_rules) == 1
+    assert approval_rules[0].name == approval_rule_name
+
+    rule_to_be_changed = group.approval_rules.get(approval_rules[0].id)
+    rule_to_be_changed.name = new_approval_rule_name
+    rule_to_be_changed.approvals_required = new_approval_rule_approvals_required
+    rule_to_be_changed.save()
+
+    # Set the flag to return updated rule in the list response
+    list_request_options["updated_first_rule"] = True
+
+    # After: changed approval rule
+    approval_rules = group.approval_rules.list()
+    assert len(approval_rules) == 1
+    assert approval_rules[0].name == new_approval_rule_name
+    assert (
+        repr(approval_rules[0])
+        == f"<GroupApprovalRule id:{approval_rule_id} name:{new_approval_rule_name}>"
+    )
+
+
+def test_create_group_mr_approval_rule(group, resp_group_approval_rules):
+    _, list_request_options = resp_group_approval_rules
+
+    # Before: existing approval rules
+    approval_rules = group.approval_rules.list()
+    assert len(approval_rules) == 1
+
+    new_approval_rule_data = {
+        "name": new_approval_rule_name,
+        "approvals_required": new_approval_rule_approvals_required,
+        "rule_type": "regular",
+        "user_ids": new_approval_rule_user_ids,
+        "group_ids": group_ids,
+    }
+
+    response = group.approval_rules.create(new_approval_rule_data)
+    assert response.approvals_required == new_approval_rule_approvals_required
+    assert len(response.eligible_approvers) == len(new_approval_rule_user_ids)
+    assert response.eligible_approvers[0]["id"] == new_approval_rule_user_ids[0]
+    assert response.name == new_approval_rule_name
+
+    # Set the flag to include the new rule in the list response
+    list_request_options["include_newly_created_rule"] = True
+
+    # After: list approval rules
+    approval_rules = group.approval_rules.list()
+    assert len(approval_rules) == 2
+    assert approval_rules[1].name == new_approval_rule_name
+    assert approval_rules[1].approvals_required == new_approval_rule_approvals_required
diff --git a/tests/unit/objects/test_groups.py b/tests/unit/objects/test_groups.py
new file mode 100644
index 000000000..7d1510c8d
--- /dev/null
+++ b/tests/unit/objects/test_groups.py
@@ -0,0 +1,491 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/groups.html
+"""
+
+import re
+
+import pytest
+import responses
+
+import gitlab
+from gitlab.v4.objects import (
+    GroupDescendantGroup,
+    GroupLDAPGroupLink,
+    GroupSAMLGroupLink,
+    GroupSubgroup,
+)
+from gitlab.v4.objects.projects import GroupProject, SharedProject
+
+content = {"name": "name", "id": 1, "path": "path"}
+ldap_group_links_content = [
+    {
+        "cn": None,
+        "group_access": 40,
+        "provider": "ldapmain",
+        "filter": "(memberOf=cn=some_group,ou=groups,ou=fake_ou,dc=sub_dc,dc=example,dc=tld)",
+    }
+]
+saml_group_links_content = [{"name": "saml-group-1", "access_level": 10}]
+create_saml_group_link_request_body = {
+    "saml_group_name": "saml-group-1",
+    "access_level": 10,
+}
+projects_content = [
+    {
+        "id": 9,
+        "description": "foo",
+        "default_branch": "master",
+        "name": "Html5 Boilerplate",
+        "name_with_namespace": "Experimental / Html5 Boilerplate",
+        "path": "html5-boilerplate",
+        "path_with_namespace": "h5bp/html5-boilerplate",
+        "namespace": {"id": 5, "name": "Experimental", "path": "h5bp", "kind": "group"},
+    }
+]
+subgroup_descgroup_content = [
+    {
+        "id": 2,
+        "name": "Bar Group",
+        "path": "foo/bar",
+        "description": "A subgroup of Foo Group",
+        "visibility": "public",
+        "share_with_group_lock": False,
+        "require_two_factor_authentication": False,
+        "two_factor_grace_period": 48,
+        "project_creation_level": "developer",
+        "auto_devops_enabled": None,
+        "subgroup_creation_level": "owner",
+        "emails_disabled": None,
+        "mentions_disabled": None,
+        "lfs_enabled": True,
+        "default_branch_protection": 2,
+        "avatar_url": "http://gitlab.example.com/uploads/group/avatar/1/bar.jpg",
+        "web_url": "http://gitlab.example.com/groups/foo/bar",
+        "request_access_enabled": False,
+        "full_name": "Bar Group",
+        "full_path": "foo/bar",
+        "file_template_project_id": 1,
+        "parent_id": 123,
+        "created_at": "2020-01-15T12:36:29.590Z",
+    }
+]
+push_rules_content = {
+    "id": 2,
+    "created_at": "2020-08-17T19:09:19.580Z",
+    "commit_message_regex": "[a-zA-Z]",
+    "commit_message_negative_regex": "[x+]",
+    "branch_name_regex": "[a-z]",
+    "deny_delete_tag": True,
+    "member_check": True,
+    "prevent_secrets": True,
+    "author_email_regex": "^[A-Za-z0-9.]+@gitlab.com$",
+    "file_name_regex": "(exe)$",
+    "max_file_size": 100,
+}
+
+service_account_content = {
+    "name": "gitlab-service-account",
+    "username": "gitlab-service-account",
+}
+
+
+@pytest.fixture
+def resp_groups():
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/groups/1",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/groups",
+            json=[content],
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/groups",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_group_projects():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=re.compile(r"http://localhost/api/v4/groups/1/projects(/shared)?"),
+            json=projects_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_subgroups_descendant_groups():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=re.compile(
+                r"http://localhost/api/v4/groups/1/(subgroups|descendant_groups)"
+            ),
+            json=subgroup_descgroup_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_create_import(accepted_content):
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/groups/import",
+            json=accepted_content,
+            content_type="application/json",
+            status=202,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_transfer_group():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/groups/1/transfer",
+            json=content,
+            content_type="application/json",
+            status=200,
+            match=[
+                responses.matchers.json_params_matcher({"group_id": "test-namespace"})
+            ],
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_push_rules_group():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/groups/1/push_rule",
+            json=push_rules_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_create_push_rules_group():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/groups/1/push_rule",
+            json=push_rules_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_update_push_rules_group():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/groups/1/push_rule",
+            json=push_rules_content,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.PUT,
+            url="http://localhost/api/v4/groups/1/push_rule",
+            json=push_rules_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_delete_push_rules_group():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/groups/1/push_rule",
+            json=push_rules_content,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/groups/1/push_rule",
+            status=204,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_ldap_group_links():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/groups/1/ldap_group_links",
+            json=ldap_group_links_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_saml_group_links():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/groups/1/saml_group_links",
+            json=saml_group_links_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_saml_group_link():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/groups/1/saml_group_links/saml-group-1",
+            json=saml_group_links_content[0],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_create_saml_group_link():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/groups/1/saml_group_links",
+            match=[
+                responses.matchers.json_params_matcher(
+                    create_saml_group_link_request_body
+                )
+            ],
+            json=saml_group_links_content[0],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_delete_saml_group_link():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/groups/1/saml_group_links",
+            match=[
+                responses.matchers.json_params_matcher(
+                    create_saml_group_link_request_body
+                )
+            ],
+            json=saml_group_links_content[0],
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/groups/1/saml_group_links/saml-group-1",
+            status=204,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_restore_group(created_content):
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/groups/1/restore",
+            json=created_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_create_group_service_account():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/groups/1/service_accounts",
+            json=service_account_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_get_group(gl, resp_groups):
+    data = gl.groups.get(1)
+    assert isinstance(data, gitlab.v4.objects.Group)
+    assert data.name == "name"
+    assert data.path == "path"
+    assert data.id == 1
+
+
+def test_create_group(gl, resp_groups):
+    name, path = "name", "path"
+    data = gl.groups.create({"name": name, "path": path})
+    assert isinstance(data, gitlab.v4.objects.Group)
+    assert data.name == name
+    assert data.path == path
+
+
+def test_create_group_export(group, resp_export):
+    export = group.exports.create()
+    assert export.message == "202 Accepted"
+
+
+def test_list_group_projects(group, resp_list_group_projects):
+    projects = group.projects.list()
+    assert isinstance(projects[0], GroupProject)
+    assert projects[0].path == projects_content[0]["path"]
+
+
+def test_list_group_shared_projects(group, resp_list_group_projects):
+    projects = group.shared_projects.list()
+    assert isinstance(projects[0], SharedProject)
+    assert projects[0].path == projects_content[0]["path"]
+
+
+def test_list_group_subgroups(group, resp_list_subgroups_descendant_groups):
+    subgroups = group.subgroups.list()
+    assert isinstance(subgroups[0], GroupSubgroup)
+    assert subgroups[0].path == subgroup_descgroup_content[0]["path"]
+
+
+def test_list_group_descendant_groups(group, resp_list_subgroups_descendant_groups):
+    descendant_groups = group.descendant_groups.list()
+    assert isinstance(descendant_groups[0], GroupDescendantGroup)
+    assert descendant_groups[0].path == subgroup_descgroup_content[0]["path"]
+
+
+def test_list_ldap_group_links(group, resp_list_ldap_group_links):
+    ldap_group_links = group.ldap_group_links.list()
+    assert isinstance(ldap_group_links[0], GroupLDAPGroupLink)
+    assert ldap_group_links[0].provider == ldap_group_links_content[0]["provider"]
+
+
+@pytest.mark.skip("GitLab API endpoint not implemented")
+def test_refresh_group_export_status(group, resp_export):
+    export = group.exports.create()
+    export.refresh()
+    assert export.export_status == "finished"
+
+
+def test_download_group_export(group, resp_export, binary_content):
+    export = group.exports.create()
+    download = export.download()
+    assert isinstance(download, bytes)
+    assert download == binary_content
+
+
+def test_import_group(gl, resp_create_import):
+    group_import = gl.groups.import_group("file", "api-group", "API Group")
+    assert group_import["message"] == "202 Accepted"
+
+
+@pytest.mark.skip("GitLab API endpoint not implemented")
+def test_refresh_group_import_status(group, resp_groups):
+    group_import = group.imports.get()
+    group_import.refresh()
+    assert group_import.import_status == "finished"
+
+
+def test_transfer_group(gl, resp_transfer_group):
+    group = gl.groups.get(1, lazy=True)
+    group.transfer("test-namespace")
+
+
+def test_list_group_push_rules(group, resp_list_push_rules_group):
+    pr = group.pushrules.get()
+    assert pr
+    assert pr.deny_delete_tag
+
+
+def test_create_group_push_rule(group, resp_create_push_rules_group):
+    group.pushrules.create({"deny_delete_tag": True})
+
+
+def test_update_group_push_rule(group, resp_update_push_rules_group):
+    pr = group.pushrules.get()
+    pr.deny_delete_tag = False
+    pr.save()
+
+
+def test_delete_group_push_rule(group, resp_delete_push_rules_group):
+    pr = group.pushrules.get()
+    pr.delete()
+
+
+def test_list_saml_group_links(group, resp_list_saml_group_links):
+    saml_group_links = group.saml_group_links.list()
+    assert isinstance(saml_group_links[0], GroupSAMLGroupLink)
+    assert saml_group_links[0].name == saml_group_links_content[0]["name"]
+    assert (
+        saml_group_links[0].access_level == saml_group_links_content[0]["access_level"]
+    )
+
+
+def test_get_saml_group_link(group, resp_get_saml_group_link):
+    saml_group_link = group.saml_group_links.get("saml-group-1")
+    assert isinstance(saml_group_link, GroupSAMLGroupLink)
+    assert saml_group_link.name == saml_group_links_content[0]["name"]
+    assert saml_group_link.access_level == saml_group_links_content[0]["access_level"]
+
+
+def test_create_saml_group_link(group, resp_create_saml_group_link):
+    saml_group_link = group.saml_group_links.create(create_saml_group_link_request_body)
+    assert isinstance(saml_group_link, GroupSAMLGroupLink)
+    assert (
+        saml_group_link.name == create_saml_group_link_request_body["saml_group_name"]
+    )
+    assert (
+        saml_group_link.access_level
+        == create_saml_group_link_request_body["access_level"]
+    )
+
+
+def test_delete_saml_group_link(group, resp_delete_saml_group_link):
+    saml_group_link = group.saml_group_links.create(create_saml_group_link_request_body)
+    saml_group_link.delete()
+
+
+def test_group_restore(group, resp_restore_group):
+    group.restore()
+
+
+def test_create_group_service_account(group, resp_create_group_service_account):
+    service_account = group.service_accounts.create(
+        {"name": "gitlab-service-account", "username": "gitlab-service-account"}
+    )
+    assert service_account.name == "gitlab-service-account"
+    assert service_account.username == "gitlab-service-account"
diff --git a/tests/unit/objects/test_hooks.py b/tests/unit/objects/test_hooks.py
new file mode 100644
index 000000000..9cff206f5
--- /dev/null
+++ b/tests/unit/objects/test_hooks.py
@@ -0,0 +1,259 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/system_hooks.html
+GitLab API: https://docs.gitlab.com/ce/api/groups.html#hooks
+GitLab API: https://docs.gitlab.com/ee/api/projects.html#hooks
+"""
+
+import re
+
+import pytest
+import responses
+
+import gitlab
+from gitlab.v4.objects import GroupHook, Hook, ProjectHook
+
+hooks_content = [
+    {"id": 1, "url": "testurl", "push_events": True, "tag_push_events": True},
+    {"id": 2, "url": "testurl_second", "push_events": False, "tag_push_events": False},
+]
+
+hook_content = hooks_content[0]
+
+
+@pytest.fixture
+def resp_hooks_list():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=re.compile(r"http://localhost/api/v4/((groups|projects)/1/|)hooks"),
+            json=hooks_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_hook_get():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=re.compile(r"http://localhost/api/v4/((groups|projects)/1/|)hooks/1"),
+            json=hook_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_hook_create():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url=re.compile(r"http://localhost/api/v4/((groups|projects)/1/|)hooks"),
+            json=hook_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_hook_update():
+    with responses.RequestsMock() as rsps:
+        pattern = re.compile(r"http://localhost/api/v4/((groups|projects)/1/|)hooks/1")
+        rsps.add(
+            method=responses.GET,
+            url=pattern,
+            json=hook_content,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.PUT,
+            url=pattern,
+            json=hook_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_hook_test():
+    with responses.RequestsMock() as rsps:
+        hook_pattern = re.compile(
+            r"http://localhost/api/v4/((groups|projects)/1/|)hooks/1"
+        )
+        test_pattern = re.compile(
+            r"http://localhost/api/v4/((groups|projects)/1/|)hooks/1/test/[a-z_]+"
+        )
+        rsps.add(
+            method=responses.GET,
+            url=hook_pattern,
+            json=hook_content,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.POST,
+            url=test_pattern,
+            json={"message": "201 Created"},
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_hook_test_error():
+    with responses.RequestsMock() as rsps:
+        hook_pattern = re.compile(
+            r"http://localhost/api/v4/((groups|projects)/1/|)hooks/1"
+        )
+        test_pattern = re.compile(
+            r"http://localhost/api/v4/((groups|projects)/1/|)hooks/1/test/[a-z_]+"
+        )
+        rsps.add(
+            method=responses.GET,
+            url=hook_pattern,
+            json=hook_content,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.POST,
+            url=test_pattern,
+            json={"message": "<html>error</html>"},
+            content_type="application/json",
+            status=422,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_hook_delete():
+    with responses.RequestsMock() as rsps:
+        pattern = re.compile(r"http://localhost/api/v4/((groups|projects)/1/|)hooks/1")
+        rsps.add(
+            method=responses.GET,
+            url=pattern,
+            json=hook_content,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(method=responses.DELETE, url=pattern, status=204)
+        yield rsps
+
+
+def test_list_system_hooks(gl, resp_hooks_list):
+    hooks = gl.hooks.list()
+    assert hooks[0].id == 1
+    assert hooks[0].url == "testurl"
+    assert hooks[1].id == 2
+    assert hooks[1].url == "testurl_second"
+
+
+def test_get_system_hook(gl, resp_hook_get):
+    data = gl.hooks.get(1)
+    assert isinstance(data, Hook)
+    assert data.url == "testurl"
+    assert data.id == 1
+
+
+def test_create_system_hook(gl, resp_hook_create):
+    hook = gl.hooks.create(hook_content)
+    assert hook.url == "testurl"
+    assert hook.push_events is True
+    assert hook.tag_push_events is True
+
+
+# there is no update method for system hooks
+
+
+def test_delete_system_hook(gl, resp_hook_delete):
+    hook = gl.hooks.get(1)
+    hook.delete()
+    gl.hooks.delete(1)
+
+
+def test_list_group_hooks(group, resp_hooks_list):
+    hooks = group.hooks.list()
+    assert hooks[0].id == 1
+    assert hooks[0].url == "testurl"
+    assert hooks[1].id == 2
+    assert hooks[1].url == "testurl_second"
+
+
+def test_get_group_hook(group, resp_hook_get):
+    data = group.hooks.get(1)
+    assert isinstance(data, GroupHook)
+    assert data.url == "testurl"
+    assert data.id == 1
+
+
+def test_create_group_hook(group, resp_hook_create):
+    hook = group.hooks.create(hook_content)
+    assert hook.url == "testurl"
+    assert hook.push_events is True
+    assert hook.tag_push_events is True
+
+
+def test_update_group_hook(group, resp_hook_update):
+    hook = group.hooks.get(1)
+    assert hook.id == 1
+    hook.url = "testurl_more"
+    hook.save()
+
+
+def test_delete_group_hook(group, resp_hook_delete):
+    hook = group.hooks.get(1)
+    hook.delete()
+    group.hooks.delete(1)
+
+
+def test_test_group_hook(group, resp_hook_test):
+    hook = group.hooks.get(1)
+    hook.test("push_events")
+
+
+def test_test_error_group_hook(group, resp_hook_test_error):
+    hook = group.hooks.get(1)
+    with pytest.raises(gitlab.exceptions.GitlabHookTestError):
+        hook.test("push_events")
+
+
+def test_list_project_hooks(project, resp_hooks_list):
+    hooks = project.hooks.list()
+    assert hooks[0].id == 1
+    assert hooks[0].url == "testurl"
+    assert hooks[1].id == 2
+    assert hooks[1].url == "testurl_second"
+
+
+def test_get_project_hook(project, resp_hook_get):
+    data = project.hooks.get(1)
+    assert isinstance(data, ProjectHook)
+    assert data.url == "testurl"
+    assert data.id == 1
+
+
+def test_create_project_hook(project, resp_hook_create):
+    hook = project.hooks.create(hook_content)
+    assert hook.url == "testurl"
+    assert hook.push_events is True
+    assert hook.tag_push_events is True
+
+
+def test_update_project_hook(project, resp_hook_update):
+    hook = project.hooks.get(1)
+    assert hook.id == 1
+    hook.url = "testurl_more"
+    hook.save()
+
+
+def test_delete_project_hook(project, resp_hook_delete):
+    hook = project.hooks.get(1)
+    hook.delete()
+    project.hooks.delete(1)
diff --git a/tests/unit/objects/test_invitations.py b/tests/unit/objects/test_invitations.py
new file mode 100644
index 000000000..e806de02b
--- /dev/null
+++ b/tests/unit/objects/test_invitations.py
@@ -0,0 +1,152 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/invitations.html
+"""
+
+import re
+
+import pytest
+import responses
+
+from gitlab.exceptions import GitlabInvitationError
+
+create_content = {"email": "email@example.com", "access_level": 30}
+success_content = {"status": "success"}
+error_content = {
+    "status": "error",
+    "message": {
+        "test@example.com": "Invite email has already been taken",
+        "test2@example.com": "User already exists in source",
+        "test_username": "Access level is not included in the list",
+    },
+}
+invitations_content = [
+    {
+        "id": 1,
+        "invite_email": "member@example.org",
+        "created_at": "2020-10-22T14:13:35Z",
+        "access_level": 30,
+        "expires_at": "2020-11-22T14:13:35Z",
+        "user_name": "Raymond Smith",
+        "created_by_name": "Administrator",
+    }
+]
+invitation_content = {"expires_at": "2012-10-22T14:13:35Z", "access_level": 40}
+
+
+@pytest.fixture
+def resp_invitations_list():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=re.compile(r"http://localhost/api/v4/(groups|projects)/1/invitations"),
+            json=invitations_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_invitation_create():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url=re.compile(r"http://localhost/api/v4/(groups|projects)/1/invitations"),
+            json=success_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_invitation_create_error():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url=re.compile(r"http://localhost/api/v4/(groups|projects)/1/invitations"),
+            json=error_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_invitation_update():
+    with responses.RequestsMock() as rsps:
+        pattern = re.compile(
+            r"http://localhost/api/v4/(groups|projects)/1/invitations/email%40example.com"
+        )
+        rsps.add(
+            method=responses.PUT,
+            url=pattern,
+            json=invitation_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_invitation_delete():
+    with responses.RequestsMock() as rsps:
+        pattern = re.compile(
+            r"http://localhost/api/v4/(groups|projects)/1/invitations/email%40example.com"
+        )
+        rsps.add(method=responses.DELETE, url=pattern, status=204)
+        yield rsps
+
+
+def test_list_group_invitations(group, resp_invitations_list):
+    invitations = group.invitations.list()
+    assert invitations[0].invite_email == "member@example.org"
+
+
+def test_create_group_invitation(group, resp_invitation_create):
+    invitation = group.invitations.create(create_content)
+    assert invitation.status == "success"
+
+
+def test_update_group_invitation(group, resp_invitation_update):
+    invitation = group.invitations.get("email@example.com", lazy=True)
+    invitation.access_level = 30
+    invitation.save()
+
+
+def test_delete_group_invitation(group, resp_invitation_delete):
+    invitation = group.invitations.get("email@example.com", lazy=True)
+    invitation.delete()
+    group.invitations.delete("email@example.com")
+
+
+def test_list_project_invitations(project, resp_invitations_list):
+    invitations = project.invitations.list()
+    assert invitations[0].invite_email == "member@example.org"
+
+
+def test_create_project_invitation(project, resp_invitation_create):
+    invitation = project.invitations.create(create_content)
+    assert invitation.status == "success"
+
+
+def test_update_project_invitation(project, resp_invitation_update):
+    invitation = project.invitations.get("email@example.com", lazy=True)
+    invitation.access_level = 30
+    invitation.save()
+
+
+def test_delete_project_invitation(project, resp_invitation_delete):
+    invitation = project.invitations.get("email@example.com", lazy=True)
+    invitation.delete()
+    project.invitations.delete("email@example.com")
+
+
+def test_create_group_invitation_raises(group, resp_invitation_create_error):
+    with pytest.raises(GitlabInvitationError, match="User already exists"):
+        group.invitations.create(create_content)
+
+
+def test_create_project_invitation_raises(project, resp_invitation_create_error):
+    with pytest.raises(GitlabInvitationError, match="User already exists"):
+        project.invitations.create(create_content)
diff --git a/tests/unit/objects/test_issues.py b/tests/unit/objects/test_issues.py
new file mode 100644
index 000000000..02799b580
--- /dev/null
+++ b/tests/unit/objects/test_issues.py
@@ -0,0 +1,109 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/issues.html
+"""
+
+import re
+
+import pytest
+import responses
+
+from gitlab.v4.objects import (
+    GroupIssuesStatistics,
+    IssuesStatistics,
+    ProjectIssuesStatistics,
+)
+
+
+@pytest.fixture
+def resp_list_issues():
+    content = [{"name": "name", "id": 1}, {"name": "other_name", "id": 2}]
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/issues",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_issue():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/issues/1",
+            json={"name": "name", "id": 1},
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_reorder_issue():
+    match_params = {"move_after_id": 2, "move_before_id": 3}
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.PUT,
+            url="http://localhost/api/v4/projects/1/issues/1/reorder",
+            json={"name": "name", "id": 1},
+            content_type="application/json",
+            status=200,
+            match=[responses.matchers.json_params_matcher(match_params)],
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_issue_statistics():
+    content = {"statistics": {"counts": {"all": 20, "closed": 5, "opened": 15}}}
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=re.compile(
+                r"http://localhost/api/v4/((groups|projects)/1/)?issues_statistics"
+            ),
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_list_issues(gl, resp_list_issues):
+    data = gl.issues.list()
+    assert data[1].id == 2
+    assert data[1].name == "other_name"
+
+
+def test_get_issue(gl, resp_get_issue):
+    issue = gl.issues.get(1)
+    assert issue.id == 1
+    assert issue.name == "name"
+
+
+def test_reorder_issue(project, resp_reorder_issue):
+    issue = project.issues.get(1, lazy=True)
+    issue.reorder(move_after_id=2, move_before_id=3)
+
+
+def test_get_issues_statistics(gl, resp_issue_statistics):
+    statistics = gl.issues_statistics.get()
+    assert isinstance(statistics, IssuesStatistics)
+    assert statistics.statistics["counts"]["all"] == 20
+
+
+def test_get_group_issues_statistics(group, resp_issue_statistics):
+    statistics = group.issues_statistics.get()
+    assert isinstance(statistics, GroupIssuesStatistics)
+    assert statistics.statistics["counts"]["all"] == 20
+
+
+def test_get_project_issues_statistics(project, resp_issue_statistics):
+    statistics = project.issues_statistics.get()
+    assert isinstance(statistics, ProjectIssuesStatistics)
+    assert statistics.statistics["counts"]["all"] == 20
diff --git a/tests/unit/objects/test_iterations.py b/tests/unit/objects/test_iterations.py
new file mode 100644
index 000000000..084869155
--- /dev/null
+++ b/tests/unit/objects/test_iterations.py
@@ -0,0 +1,47 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/iterations.html
+"""
+
+import re
+
+import pytest
+import responses
+
+iterations_content = [
+    {
+        "id": 53,
+        "iid": 13,
+        "group_id": 5,
+        "title": "Iteration II",
+        "description": "Ipsum Lorem ipsum",
+        "state": 2,
+        "created_at": "2020-01-27T05:07:12.573Z",
+        "updated_at": "2020-01-27T05:07:12.573Z",
+        "due_date": "2020-02-01",
+        "start_date": "2020-02-14",
+        "web_url": "http://gitlab.example.com/groups/my-group/-/iterations/13",
+    }
+]
+
+
+@pytest.fixture
+def resp_iterations_list():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=re.compile(r"http://localhost/api/v4/(groups|projects)/1/iterations"),
+            json=iterations_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_list_group_iterations(group, resp_iterations_list):
+    iterations = group.iterations.list()
+    assert iterations[0].group_id == 5
+
+
+def test_list_project_iterations(project, resp_iterations_list):
+    iterations = project.iterations.list()
+    assert iterations[0].group_id == 5
diff --git a/tests/unit/objects/test_job_artifacts.py b/tests/unit/objects/test_job_artifacts.py
new file mode 100644
index 000000000..e7fd06f9e
--- /dev/null
+++ b/tests/unit/objects/test_job_artifacts.py
@@ -0,0 +1,72 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/job_artifacts.html
+"""
+
+import pytest
+import responses
+
+ref_name = "main"
+job = "build"
+
+
+@pytest.fixture
+def resp_artifacts_by_ref_name(binary_content):
+    url = f"http://localhost/api/v4/projects/1/jobs/artifacts/{ref_name}/download?job={job}"
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=url,
+            body=binary_content,
+            content_type="application/octet-stream",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_project_artifacts_delete():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/projects/1/artifacts",
+            status=204,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_job_artifact_bytes_range(binary_content):
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/jobs/123/artifacts",
+            body=binary_content[:10],
+            content_type="application/octet-stream",
+            status=206,
+            match=[responses.matchers.header_matcher({"Range": "bytes=0-9"})],
+        )
+        yield rsps
+
+
+def test_project_artifacts_delete(gl, resp_project_artifacts_delete):
+    project = gl.projects.get(1, lazy=True)
+    project.artifacts.delete()
+
+
+def test_project_artifacts_download_by_ref_name(
+    gl, binary_content, resp_artifacts_by_ref_name
+):
+    project = gl.projects.get(1, lazy=True)
+    artifacts = project.artifacts.download(ref_name=ref_name, job=job)
+    assert artifacts == binary_content
+
+
+def test_job_artifact_download_bytes_range(
+    gl, binary_content, resp_job_artifact_bytes_range
+):
+    project = gl.projects.get(1, lazy=True)
+    job = project.jobs.get(123, lazy=True)
+
+    artifacts = job.artifacts(extra_headers={"Range": "bytes=0-9"})
+    assert len(artifacts) == 10
diff --git a/tests/unit/objects/test_job_token_scope.py b/tests/unit/objects/test_job_token_scope.py
new file mode 100644
index 000000000..5a594d85c
--- /dev/null
+++ b/tests/unit/objects/test_job_token_scope.py
@@ -0,0 +1,193 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/project_job_token_scopes.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import ProjectJobTokenScope
+from gitlab.v4.objects.job_token_scope import (
+    AllowlistGroupManager,
+    AllowlistProjectManager,
+)
+
+job_token_scope_content = {"inbound_enabled": True, "outbound_enabled": False}
+
+project_allowlist_content = [
+    {
+        "id": 4,
+        "description": "",
+        "name": "Diaspora Client",
+        "name_with_namespace": "Diaspora / Diaspora Client",
+        "path": "diaspora-client",
+        "path_with_namespace": "diaspora/diaspora-client",
+        "created_at": "2013-09-30T13:46:02Z",
+        "default_branch": "main",
+        "tag_list": ["example", "disapora client"],
+        "topics": ["example", "disapora client"],
+        "ssh_url_to_repo": "git@gitlab.example.com:diaspora/diaspora-client.git",
+        "http_url_to_repo": "https://gitlab.example.com/diaspora/diaspora-client.git",
+        "web_url": "https://gitlab.example.com/diaspora/diaspora-client",
+        "avatar_url": "https://gitlab.example.com/uploads/project/avatar/4/uploads/avatar.png",
+        "star_count": 0,
+        "last_activity_at": "2013-09-30T13:46:02Z",
+        "namespace": {
+            "id": 2,
+            "name": "Diaspora",
+            "path": "diaspora",
+            "kind": "group",
+            "full_path": "diaspora",
+            "parent_id": "",
+            "avatar_url": "",
+            "web_url": "https://gitlab.example.com/diaspora",
+        },
+    }
+]
+
+project_allowlist_created_content = {"target_project_id": 2, "project_id": 1}
+
+groups_allowlist_content = [
+    {
+        "id": 4,
+        "web_url": "https://gitlab.example.com/groups/diaspora/diaspora-group",
+        "name": "namegroup",
+    }
+]
+
+group_allowlist_created_content = {"target_group_id": 4, "project_id": 1}
+
+
+@pytest.fixture
+def resp_get_job_token_scope():
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/job_token_scope",
+            json=job_token_scope_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_allowlist():
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/job_token_scope/allowlist",
+            json=project_allowlist_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_add_to_allowlist():
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/job_token_scope/allowlist",
+            json=project_allowlist_created_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_groups_allowlist():
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/job_token_scope/groups_allowlist",
+            json=groups_allowlist_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_add_to_groups_allowlist():
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/job_token_scope/groups_allowlist",
+            json=group_allowlist_created_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_patch_job_token_scope():
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.PATCH,
+            url="http://localhost/api/v4/projects/1/job_token_scope",
+            status=204,
+            match=[responses.matchers.json_params_matcher({"enabled": False})],
+        )
+        yield rsps
+
+
+@pytest.fixture
+def job_token_scope(project, resp_get_job_token_scope):
+    return project.job_token_scope.get()
+
+
+def test_get_job_token_scope(project, resp_get_job_token_scope):
+    scope = project.job_token_scope.get()
+    assert isinstance(scope, ProjectJobTokenScope)
+    assert scope.inbound_enabled is True
+
+
+def test_refresh_job_token_scope(job_token_scope, resp_get_job_token_scope):
+    job_token_scope.refresh()
+    assert job_token_scope.inbound_enabled is True
+
+
+def test_save_job_token_scope(job_token_scope, resp_patch_job_token_scope):
+    job_token_scope.enabled = False
+    job_token_scope.save()
+
+
+def test_update_job_token_scope(project, resp_patch_job_token_scope):
+    project.job_token_scope.update(new_data={"enabled": False})
+
+
+def test_get_projects_allowlist(job_token_scope, resp_get_allowlist):
+    allowlist = job_token_scope.allowlist
+    assert isinstance(allowlist, AllowlistProjectManager)
+
+    allowlist_content = allowlist.list()
+    assert isinstance(allowlist_content, list)
+    assert allowlist_content[0].get_id() == 4
+
+
+def test_add_project_to_allowlist(job_token_scope, resp_add_to_allowlist):
+    allowlist = job_token_scope.allowlist
+    assert isinstance(allowlist, AllowlistProjectManager)
+
+    resp = allowlist.create({"target_project_id": 2})
+    assert resp.get_id() == 2
+
+
+def test_get_groups_allowlist(job_token_scope, resp_get_groups_allowlist):
+    allowlist = job_token_scope.groups_allowlist
+    assert isinstance(allowlist, AllowlistGroupManager)
+
+    allowlist_content = allowlist.list()
+    assert isinstance(allowlist_content, list)
+    assert allowlist_content[0].get_id() == 4
+
+
+def test_add_group_to_allowlist(job_token_scope, resp_add_to_groups_allowlist):
+    allowlist = job_token_scope.groups_allowlist
+    assert isinstance(allowlist, AllowlistGroupManager)
+
+    resp = allowlist.create({"target_group_id": 4})
+    assert resp.get_id() == 4
diff --git a/tests/unit/objects/test_jobs.py b/tests/unit/objects/test_jobs.py
new file mode 100644
index 000000000..be1d184ec
--- /dev/null
+++ b/tests/unit/objects/test_jobs.py
@@ -0,0 +1,158 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/jobs.html
+"""
+
+from functools import partial
+
+import pytest
+import responses
+
+from gitlab.v4.objects import ProjectJob
+
+failed_job_content = {
+    "commit": {"author_email": "admin@example.com", "author_name": "Administrator"},
+    "coverage": None,
+    "allow_failure": False,
+    "created_at": "2015-12-24T15:51:21.880Z",
+    "started_at": "2015-12-24T17:54:30.733Z",
+    "finished_at": "2015-12-24T17:54:31.198Z",
+    "duration": 0.465,
+    "queued_duration": 0.010,
+    "artifacts_expire_at": "2016-01-23T17:54:31.198Z",
+    "tag_list": ["docker runner", "macos-10.15"],
+    "id": 1,
+    "name": "rubocop",
+    "pipeline": {"id": 1, "project_id": 1},
+    "ref": "main",
+    "artifacts": [],
+    "runner": None,
+    "stage": "test",
+    "status": "failed",
+    "tag": False,
+    "web_url": "https://example.com/foo/bar/-/jobs/1",
+    "user": {"id": 1},
+}
+
+success_job_content = {
+    **failed_job_content,
+    "status": "success",
+    "id": failed_job_content["id"] + 1,
+}
+
+
+@pytest.fixture
+def resp_get_job():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/jobs/1",
+            json=failed_job_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_cancel_job():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/jobs/1/cancel",
+            json=failed_job_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_retry_job():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/jobs/1/retry",
+            json=failed_job_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_job():
+    urls = [
+        "http://localhost/api/v4/projects/1/jobs",
+        "http://localhost/api/v4/projects/1/pipelines/1/jobs",
+    ]
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        register_endpoint = partial(
+            rsps.add, method=responses.GET, content_type="application/json", status=200
+        )
+        for url in urls:
+            register_endpoint(
+                url=url,
+                json=[failed_job_content],
+                match=[responses.matchers.query_param_matcher({"scope[]": "failed"})],
+            )
+            register_endpoint(
+                url=url,
+                json=[success_job_content],
+                match=[responses.matchers.query_param_matcher({"scope[]": "success"})],
+            )
+            register_endpoint(
+                url=url,
+                json=[success_job_content, failed_job_content],
+                match=[
+                    responses.matchers.query_string_matcher(
+                        "scope[]=success&scope[]failed"
+                    )
+                ],
+            )
+            register_endpoint(url=url, json=[success_job_content, failed_job_content])
+        yield rsps
+
+
+def test_get_project_job(project, resp_get_job):
+    job = project.jobs.get(1)
+    assert isinstance(job, ProjectJob)
+    assert job.ref == "main"
+
+
+def test_cancel_project_job(project, resp_cancel_job):
+    job = project.jobs.get(1, lazy=True)
+
+    output = job.cancel()
+    assert output["ref"] == "main"
+
+
+def test_retry_project_job(project, resp_retry_job):
+    job = project.jobs.get(1, lazy=True)
+
+    output = job.retry()
+    assert output["ref"] == "main"
+
+
+def test_list_project_job(project, resp_list_job):
+    failed_jobs = project.jobs.list(scope="failed")
+    success_jobs = project.jobs.list(scope="success")
+    failed_and_success_jobs = project.jobs.list(scope=["failed", "success"])
+    pipeline_lazy = project.pipelines.get(1, lazy=True)
+    pjobs_failed = pipeline_lazy.jobs.list(scope="failed")
+    pjobs_success = pipeline_lazy.jobs.list(scope="success")
+    pjobs_failed_and_success = pipeline_lazy.jobs.list(scope=["failed", "success"])
+
+    prepared_urls = [c.request.url for c in resp_list_job.calls]
+
+    # Both pipelines and pipelines/jobs should behave the same way
+    # When `scope` is scalar, one can use scope=value or scope[]=value
+    assert set(failed_and_success_jobs) == set(failed_jobs + success_jobs)
+    assert set(pjobs_failed_and_success) == set(pjobs_failed + pjobs_success)
+    assert prepared_urls == [
+        "http://localhost/api/v4/projects/1/jobs?scope%5B%5D=failed",
+        "http://localhost/api/v4/projects/1/jobs?scope%5B%5D=success",
+        "http://localhost/api/v4/projects/1/jobs?scope%5B%5D=failed&scope%5B%5D=success",
+        "http://localhost/api/v4/projects/1/pipelines/1/jobs?scope%5B%5D=failed",
+        "http://localhost/api/v4/projects/1/pipelines/1/jobs?scope%5B%5D=success",
+        "http://localhost/api/v4/projects/1/pipelines/1/jobs?scope%5B%5D=failed&scope%5B%5D=success",
+    ]
diff --git a/tests/unit/objects/test_keys.py b/tests/unit/objects/test_keys.py
new file mode 100644
index 000000000..fb145846c
--- /dev/null
+++ b/tests/unit/objects/test_keys.py
@@ -0,0 +1,55 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/keys.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import Key
+
+key_content = {"id": 1, "title": "title", "key": "ssh-keytype AAAAC3Nza/key comment"}
+
+
+@pytest.fixture
+def resp_get_key_by_id():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/keys/1",
+            json=key_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_key_by_fingerprint():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/keys?fingerprint=foo",
+            json=key_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_get_key_by_id(gl, resp_get_key_by_id):
+    key = gl.keys.get(1)
+    assert isinstance(key, Key)
+    assert key.id == 1
+    assert key.title == "title"
+
+
+def test_get_key_by_fingerprint(gl, resp_get_key_by_fingerprint):
+    key = gl.keys.get(fingerprint="foo")
+    assert isinstance(key, Key)
+    assert key.id == 1
+    assert key.title == "title"
+
+
+def test_get_key_missing_attrs(gl):
+    with pytest.raises(AttributeError):
+        gl.keys.get()
diff --git a/tests/unit/objects/test_member_roles.py b/tests/unit/objects/test_member_roles.py
new file mode 100644
index 000000000..948f5a53b
--- /dev/null
+++ b/tests/unit/objects/test_member_roles.py
@@ -0,0 +1,209 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/status_checks.html
+"""
+
+import pytest
+import responses
+
+
+@pytest.fixture
+def member_roles():
+    return {
+        "id": 2,
+        "name": "Custom role",
+        "description": "Custom guest that can read code",
+        "group_id": None,
+        "base_access_level": 10,
+        "admin_cicd_variables": False,
+        "admin_compliance_framework": False,
+        "admin_group_member": False,
+        "admin_merge_request": False,
+        "admin_push_rules": False,
+        "admin_terraform_state": False,
+        "admin_vulnerability": False,
+        "admin_web_hook": False,
+        "archive_project": False,
+        "manage_deploy_tokens": False,
+        "manage_group_access_tokens": False,
+        "manage_merge_request_settings": False,
+        "manage_project_access_tokens": False,
+        "manage_security_policy_link": False,
+        "read_code": True,
+        "read_runners": False,
+        "read_dependency": False,
+        "read_vulnerability": False,
+        "remove_group": False,
+        "remove_project": False,
+    }
+
+
+@pytest.fixture
+def create_member_role():
+    return {
+        "id": 3,
+        "name": "Custom webhook manager role",
+        "description": "Custom reporter that can manage webhooks",
+        "group_id": None,
+        "base_access_level": 20,
+        "admin_cicd_variables": False,
+        "admin_compliance_framework": False,
+        "admin_group_member": False,
+        "admin_merge_request": False,
+        "admin_push_rules": False,
+        "admin_terraform_state": False,
+        "admin_vulnerability": False,
+        "admin_web_hook": True,
+        "archive_project": False,
+        "manage_deploy_tokens": False,
+        "manage_group_access_tokens": False,
+        "manage_merge_request_settings": False,
+        "manage_project_access_tokens": False,
+        "manage_security_policy_link": False,
+        "read_code": False,
+        "read_runners": False,
+        "read_dependency": False,
+        "read_vulnerability": False,
+        "remove_group": False,
+        "remove_project": False,
+    }
+
+
+@pytest.fixture
+def resp_list_member_roles(member_roles):
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/member_roles",
+            json=[member_roles],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_create_member_roles(create_member_role):
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/member_roles",
+            json=create_member_role,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_delete_member_roles():
+    content = []
+
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/member_roles/1",
+            status=204,
+        )
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/member_roles",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_group_member_roles(member_roles):
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/groups/1/member_roles",
+            json=[member_roles],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_create_group_member_roles(create_member_role):
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/groups/1/member_roles",
+            json=create_member_role,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_delete_group_member_roles():
+    content = []
+
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/groups/1/member_roles/1",
+            status=204,
+        )
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/groups/1/member_roles",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_list_member_roles(gl, resp_list_member_roles):
+    member_roles = gl.member_roles.list()
+    assert len(member_roles) == 1
+    assert member_roles[0].name == "Custom role"
+
+
+def test_create_member_roles(gl, resp_create_member_roles):
+    member_role = gl.member_roles.create(
+        {
+            "name": "Custom webhook manager role",
+            "base_access_level": 20,
+            "description": "Custom reporter that can manage webhooks",
+            "admin_web_hook": True,
+        }
+    )
+    assert member_role.name == "Custom webhook manager role"
+    assert member_role.base_access_level == 20
+
+
+def test_delete_member_roles(gl, resp_delete_member_roles):
+    gl.member_roles.delete(1)
+    member_roles_after_delete = gl.member_roles.list()
+    assert len(member_roles_after_delete) == 0
+
+
+def test_list_group_member_roles(gl, resp_list_group_member_roles):
+    member_roles = gl.groups.get(1, lazy=True).member_roles.list()
+    assert len(member_roles) == 1
+
+
+def test_create_group_member_roles(gl, resp_create_group_member_roles):
+    member_role = gl.groups.get(1, lazy=True).member_roles.create(
+        {
+            "name": "Custom webhook manager role",
+            "base_access_level": 20,
+            "description": "Custom reporter that can manage webhooks",
+            "admin_web_hook": True,
+        }
+    )
+    assert member_role.name == "Custom webhook manager role"
+    assert member_role.base_access_level == 20
+
+
+def test_delete_group_member_roles(gl, resp_delete_group_member_roles):
+    gl.groups.get(1, lazy=True).member_roles.delete(1)
+    member_roles_after_delete = gl.groups.get(1, lazy=True).member_roles.list()
+    assert len(member_roles_after_delete) == 0
diff --git a/tests/unit/objects/test_members.py b/tests/unit/objects/test_members.py
new file mode 100644
index 000000000..8ef3dff07
--- /dev/null
+++ b/tests/unit/objects/test_members.py
@@ -0,0 +1,76 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/members.html
+"""
+
+import pytest
+import responses
+
+from gitlab.const import AccessLevel
+from gitlab.v4.objects import GroupBillableMember
+
+billable_members_content = [
+    {
+        "id": 1,
+        "username": "raymond_smith",
+        "name": "Raymond Smith",
+        "state": "active",
+        "avatar_url": "https://www.gravatar.com/avatar/c2525a7f58ae3776070e44c106c48e15?s=80&d=identicon",
+        "web_url": "http://192.168.1.8:3000/root",
+        "last_activity_on": "2021-01-27",
+        "membership_type": "group_member",
+        "removable": True,
+    }
+]
+
+
+@pytest.fixture
+def resp_create_group_member():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/groups/1/members",
+            json={"id": 1, "username": "jane_doe", "access_level": 30},
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_billable_group_members():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/groups/1/billable_members",
+            json=billable_members_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_delete_billable_group_member():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/groups/1/billable_members/1",
+            status=204,
+        )
+        yield rsps
+
+
+def test_create_group_member(group, resp_create_group_member):
+    member = group.members.create({"user_id": 1, "access_level": AccessLevel.DEVELOPER})
+    assert member.access_level == 30
+
+
+def test_list_group_billable_members(group, resp_list_billable_group_members):
+    billable_members = group.billable_members.list()
+    assert isinstance(billable_members, list)
+    assert isinstance(billable_members[0], GroupBillableMember)
+    assert billable_members[0].removable is True
+
+
+def test_delete_group_billable_member(group, resp_delete_billable_group_member):
+    group.billable_members.delete(1)
diff --git a/tests/unit/objects/test_merge_request_pipelines.py b/tests/unit/objects/test_merge_request_pipelines.py
new file mode 100644
index 000000000..4a85fdc41
--- /dev/null
+++ b/tests/unit/objects/test_merge_request_pipelines.py
@@ -0,0 +1,54 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/merge_requests.html#list-mr-pipelines
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import ProjectMergeRequestPipeline
+
+pipeline_content = {
+    "id": 1,
+    "sha": "959e04d7c7a30600c894bd3c0cd0e1ce7f42c11d",
+    "ref": "main",
+    "status": "success",
+}
+
+
+@pytest.fixture()
+def resp_list_merge_request_pipelines():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/merge_requests/1/pipelines",
+            json=[pipeline_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture()
+def resp_create_merge_request_pipeline():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/merge_requests/1/pipelines",
+            json=pipeline_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+def test_list_merge_requests_pipelines(project, resp_list_merge_request_pipelines):
+    pipelines = project.mergerequests.get(1, lazy=True).pipelines.list()
+    assert len(pipelines) == 1
+    assert isinstance(pipelines[0], ProjectMergeRequestPipeline)
+    assert pipelines[0].sha == pipeline_content["sha"]
+
+
+def test_create_merge_requests_pipelines(project, resp_create_merge_request_pipeline):
+    pipeline = project.mergerequests.get(1, lazy=True).pipelines.create()
+    assert isinstance(pipeline, ProjectMergeRequestPipeline)
+    assert pipeline.sha == pipeline_content["sha"]
diff --git a/tests/unit/objects/test_merge_requests.py b/tests/unit/objects/test_merge_requests.py
new file mode 100644
index 000000000..e3db48d8f
--- /dev/null
+++ b/tests/unit/objects/test_merge_requests.py
@@ -0,0 +1,218 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ce/api/merge_requests.html
+https://docs.gitlab.com/ee/api/deployments.html#list-of-merge-requests-associated-with-a-deployment
+"""
+
+import re
+
+import pytest
+import responses
+
+from gitlab.base import RESTObjectList
+from gitlab.v4.objects import (
+    ProjectDeploymentMergeRequest,
+    ProjectIssue,
+    ProjectMergeRequest,
+    ProjectMergeRequestReviewerDetail,
+)
+
+mr_content = {
+    "id": 1,
+    "iid": 1,
+    "project_id": 3,
+    "title": "test1",
+    "description": "fixed login page css paddings",
+    "state": "merged",
+    "merged_by": {
+        "id": 87854,
+        "name": "Douwe Maan",
+        "username": "DouweM",
+        "state": "active",
+        "avatar_url": "https://gitlab.example.com/uploads/-/system/user/avatar/87854/avatar.png",
+        "web_url": "https://gitlab.com/DouweM",
+    },
+    "reviewers": [
+        {
+            "id": 2,
+            "name": "Sam Bauch",
+            "username": "kenyatta_oconnell",
+            "state": "active",
+            "avatar_url": "https://www.gravatar.com/avatar/956c92487c6f6f7616b536927e22c9a0?s=80&d=identicon",
+            "web_url": "http://gitlab.example.com//kenyatta_oconnell",
+        }
+    ],
+}
+
+reviewers_content = [
+    {
+        "user": {
+            "id": 2,
+            "name": "Sam Bauch",
+            "username": "kenyatta_oconnell",
+            "state": "active",
+            "avatar_url": "https://www.gravatar.com/avatar/956c92487c6f6f7616b536927e22c9a0?s=80&d=identicon",
+            "web_url": "http://gitlab.example.com//kenyatta_oconnell",
+        },
+        "state": "unreviewed",
+        "created_at": "2022-07-27T17:03:27.684Z",
+    }
+]
+
+related_issues = [
+    {
+        "id": 1,
+        "iid": 1,
+        "project_id": 1,
+        "title": "Fake Title for Merge Requests via API",
+        "description": "Something here",
+        "state": "closed",
+        "created_at": "2024-05-14T04:01:40.042Z",
+        "updated_at": "2024-06-13T05:29:13.661Z",
+        "closed_at": "2024-06-13T05:29:13.602Z",
+        "closed_by": {
+            "id": 2,
+            "name": "Sam Bauch",
+            "username": "kenyatta_oconnell",
+            "state": "active",
+            "avatar_url": "https://www.gravatar.com/avatar/956c92487c6f6f7616b536927e22c9a0?s=80&d=identicon",
+            "web_url": "http://gitlab.example.com/kenyatta_oconnell",
+        },
+        "labels": ["FakeCategory", "fake:ml"],
+        "assignees": [
+            {
+                "id": 2,
+                "name": "Sam Bauch",
+                "username": "kenyatta_oconnell",
+                "state": "active",
+                "avatar_url": "https://www.gravatar.com/avatar/956c92487c6f6f7616b536927e22c9a0?s=80&d=identicon",
+                "web_url": "http://gitlab.example.com/kenyatta_oconnell",
+            }
+        ],
+        "author": {
+            "id": 2,
+            "name": "Sam Bauch",
+            "username": "kenyatta_oconnell",
+            "state": "active",
+            "avatar_url": "https://www.gravatar.com/avatar/956c92487c6f6f7616b536927e22c9a0?s=80&d=identicon",
+            "web_url": "http://gitlab.example.com//kenyatta_oconnell",
+        },
+        "type": "ISSUE",
+        "assignee": {
+            "id": 4459593,
+            "username": "fakeuser",
+            "name": "Fake User",
+            "state": "active",
+            "locked": False,
+            "avatar_url": "https://example.com/uploads/-/system/user/avatar/4459593/avatar.png",
+            "web_url": "https://example.com/fakeuser",
+        },
+        "user_notes_count": 9,
+        "merge_requests_count": 0,
+        "upvotes": 1,
+        "downvotes": 0,
+        "due_date": None,
+        "confidential": False,
+        "discussion_locked": None,
+        "issue_type": "issue",
+        "web_url": "https://example.com/fakeorg/fakeproject/-/issues/461536",
+        "time_stats": {
+            "time_estimate": 0,
+            "total_time_spent": 0,
+            "human_time_estimate": None,
+            "human_total_time_spent": None,
+        },
+        "task_completion_status": {"count": 0, "completed_count": 0},
+        "weight": None,
+        "blocking_issues_count": 0,
+    }
+]
+
+
+@pytest.fixture
+def resp_list_merge_requests():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=re.compile(
+                r"http://localhost/api/v4/projects/1/(deployments/1/)?merge_requests"
+            ),
+            json=[mr_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_merge_request_reviewers():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/merge_requests/1",
+            json=mr_content,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/3/merge_requests/1/reviewers",
+            json=reviewers_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_merge_requests_related_issues():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/merge_requests/1",
+            json=mr_content,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/merge_requests/1/related_issues",
+            json=related_issues,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_list_project_merge_requests(project, resp_list_merge_requests):
+    mrs = project.mergerequests.list()
+    assert isinstance(mrs[0], ProjectMergeRequest)
+    assert mrs[0].iid == mr_content["iid"]
+
+
+def test_list_deployment_merge_requests(project, resp_list_merge_requests):
+    deployment = project.deployments.get(1, lazy=True)
+    mrs = deployment.mergerequests.list()
+    assert isinstance(mrs[0], ProjectDeploymentMergeRequest)
+    assert mrs[0].iid == mr_content["iid"]
+
+
+def test_get_merge_request_reviewers(project, resp_get_merge_request_reviewers):
+    mr = project.mergerequests.get(1)
+    reviewers_details = mr.reviewer_details.list()
+    assert isinstance(mr, ProjectMergeRequest)
+    assert isinstance(reviewers_details, list)
+    assert isinstance(reviewers_details[0], ProjectMergeRequestReviewerDetail)
+    assert mr.reviewers[0]["name"] == reviewers_details[0].user["name"]
+    assert reviewers_details[0].state == "unreviewed"
+    assert reviewers_details[0].created_at == "2022-07-27T17:03:27.684Z"
+
+
+def test_list_related_issues(project, resp_list_merge_requests_related_issues):
+    mr = project.mergerequests.get(1)
+    this_mr_related_issues = mr.related_issues()
+    the_issue = next(iter(this_mr_related_issues))
+    assert isinstance(mr, ProjectMergeRequest)
+    assert isinstance(this_mr_related_issues, RESTObjectList)
+    assert isinstance(the_issue, ProjectIssue)
+    assert the_issue.title == related_issues[0]["title"]
diff --git a/tests/unit/objects/test_merge_trains.py b/tests/unit/objects/test_merge_trains.py
new file mode 100644
index 000000000..f58d04422
--- /dev/null
+++ b/tests/unit/objects/test_merge_trains.py
@@ -0,0 +1,66 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/merge_trains.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import ProjectMergeTrain
+
+mr_content = {
+    "id": 110,
+    "merge_request": {
+        "id": 1,
+        "iid": 1,
+        "project_id": 3,
+        "title": "Test merge train",
+        "description": "",
+        "state": "merged",
+        "created_at": "2020-02-06T08:39:14.883Z",
+        "updated_at": "2020-02-06T08:40:57.038Z",
+        "web_url": "http://gitlab.example.com/root/merge-train-race-condition/-/merge_requests/1",
+    },
+    "user": {
+        "id": 1,
+        "name": "Administrator",
+        "username": "root",
+        "state": "active",
+        "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+        "web_url": "http://gitlab.example.com/root",
+    },
+    "pipeline": {
+        "id": 246,
+        "sha": "bcc17a8ffd51be1afe45605e714085df28b80b13",
+        "ref": "refs/merge-requests/1/train",
+        "status": "success",
+        "created_at": "2020-02-06T08:40:42.410Z",
+        "updated_at": "2020-02-06T08:40:46.912Z",
+        "web_url": "http://gitlab.example.com/root/merge-train-race-condition/pipelines/246",
+    },
+    "created_at": "2020-02-06T08:39:47.217Z",
+    "updated_at": "2020-02-06T08:40:57.720Z",
+    "target_branch": "feature-1580973432",
+    "status": "merged",
+    "merged_at": "2020-02-06T08:40:57.719Z",
+    "duration": 70,
+}
+
+
+@pytest.fixture
+def resp_list_merge_trains():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/merge_trains",
+            json=[mr_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_list_project_merge_requests(project, resp_list_merge_trains):
+    merge_trains = project.merge_trains.list()
+    assert isinstance(merge_trains[0], ProjectMergeTrain)
+    assert merge_trains[0].id == mr_content["id"]
diff --git a/tests/unit/objects/test_package_protection_rules.py b/tests/unit/objects/test_package_protection_rules.py
new file mode 100644
index 000000000..168441f28
--- /dev/null
+++ b/tests/unit/objects/test_package_protection_rules.py
@@ -0,0 +1,98 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/project_packages_protection_rules.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import ProjectPackageProtectionRule
+
+protected_package_content = {
+    "id": 1,
+    "project_id": 7,
+    "package_name_pattern": "v*",
+    "package_type": "npm",
+    "minimum_access_level_for_push": "maintainer",
+}
+
+
+@pytest.fixture
+def resp_list_protected_packages():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/packages/protection/rules",
+            json=[protected_package_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_create_protected_package():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/packages/protection/rules",
+            json=protected_package_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_update_protected_package():
+    updated_content = protected_package_content.copy()
+    updated_content["package_name_pattern"] = "abc*"
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.PATCH,
+            url="http://localhost/api/v4/projects/1/packages/protection/rules/1",
+            json=updated_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_delete_protected_package():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/projects/1/packages/protection/rules/1",
+            status=204,
+        )
+        yield rsps
+
+
+def test_list_project_protected_packages(project, resp_list_protected_packages):
+    protected_package = project.package_protection_rules.list()[0]
+    assert isinstance(protected_package, ProjectPackageProtectionRule)
+    assert protected_package.package_type == "npm"
+
+
+def test_create_project_protected_package(project, resp_create_protected_package):
+    protected_package = project.package_protection_rules.create(
+        {
+            "package_name_pattern": "v*",
+            "package_type": "npm",
+            "minimum_access_level_for_push": "maintainer",
+        }
+    )
+    assert isinstance(protected_package, ProjectPackageProtectionRule)
+    assert protected_package.package_type == "npm"
+
+
+def test_update_project_protected_package(project, resp_update_protected_package):
+    updated = project.package_protection_rules.update(
+        1, {"package_name_pattern": "abc*"}
+    )
+    assert updated["package_name_pattern"] == "abc*"
+
+
+def test_delete_project_protected_package(project, resp_delete_protected_package):
+    project.package_protection_rules.delete(1)
diff --git a/tests/unit/objects/test_packages.py b/tests/unit/objects/test_packages.py
new file mode 100644
index 000000000..539f16995
--- /dev/null
+++ b/tests/unit/objects/test_packages.py
@@ -0,0 +1,425 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/packages.html
+"""
+
+import re
+
+import pytest
+import responses
+
+from gitlab import exceptions as exc
+from gitlab.v4.objects import (
+    GenericPackage,
+    GroupPackage,
+    ProjectPackage,
+    ProjectPackageFile,
+    ProjectPackagePipeline,
+)
+
+package_content = {
+    "id": 1,
+    "name": "com/mycompany/my-app",
+    "version": "1.0-SNAPSHOT",
+    "package_type": "maven",
+    "_links": {
+        "web_path": "/namespace1/project1/-/packages/1",
+        "delete_api_path": "/namespace1/project1/-/packages/1",
+    },
+    "created_at": "2019-11-27T03:37:38.711Z",
+    "pipeline": {
+        "id": 123,
+        "status": "pending",
+        "ref": "new-pipeline",
+        "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+        "web_url": "https://example.com/foo/bar/pipelines/47",
+        "created_at": "2016-08-11T11:28:34.085Z",
+        "updated_at": "2016-08-11T11:32:35.169Z",
+        "user": {
+            "name": "Administrator",
+            "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+        },
+    },
+    "versions": [
+        {
+            "id": 2,
+            "version": "2.0-SNAPSHOT",
+            "created_at": "2020-04-28T04:42:11.573Z",
+            "pipeline": {
+                "id": 234,
+                "status": "pending",
+                "ref": "new-pipeline",
+                "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+                "web_url": "https://example.com/foo/bar/pipelines/58",
+                "created_at": "2016-08-11T11:28:34.085Z",
+                "updated_at": "2016-08-11T11:32:35.169Z",
+                "user": {
+                    "name": "Administrator",
+                    "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+                },
+            },
+        }
+    ],
+}
+
+package_file_content = [
+    {
+        "id": 25,
+        "package_id": 1,
+        "created_at": "2018-11-07T15:25:52.199Z",
+        "file_name": "my-app-1.5-20181107.152550-1.jar",
+        "size": 2421,
+        "file_md5": "58e6a45a629910c6ff99145a688971ac",
+        "file_sha1": "ebd193463d3915d7e22219f52740056dfd26cbfe",
+        "pipelines": [
+            {
+                "id": 123,
+                "status": "pending",
+                "ref": "new-pipeline",
+                "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+                "web_url": "https://example.com/foo/bar/pipelines/47",
+                "created_at": "2016-08-11T11:28:34.085Z",
+                "updated_at": "2016-08-11T11:32:35.169Z",
+                "user": {
+                    "name": "Administrator",
+                    "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+                },
+            }
+        ],
+    },
+    {
+        "id": 26,
+        "package_id": 1,
+        "created_at": "2018-11-07T15:25:56.776Z",
+        "file_name": "my-app-1.5-20181107.152550-1.pom",
+        "size": 1122,
+        "file_md5": "d90f11d851e17c5513586b4a7e98f1b2",
+        "file_sha1": "9608d068fe88aff85781811a42f32d97feb440b5",
+    },
+    {
+        "id": 27,
+        "package_id": 1,
+        "created_at": "2018-11-07T15:26:00.556Z",
+        "file_name": "maven-metadata.xml",
+        "size": 767,
+        "file_md5": "6dfd0cce1203145a927fef5e3a1c650c",
+        "file_sha1": "d25932de56052d320a8ac156f745ece73f6a8cd2",
+    },
+]
+
+package_pipeline_content = [
+    {
+        "id": 123,
+        "iid": 1,
+        "project_id": 1,
+        "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+        "ref": "new-pipeline",
+        "status": "failed",
+        "source": "push",
+        "created_at": "2016-08-11T11:28:34.085Z",
+        "updated_at": "2016-08-11T11:32:35.169Z",
+        "web_url": "https://example.com/foo/bar/pipelines/47",
+        "user": {
+            "id": 1,
+            "username": "root",
+            "name": "Administrator",
+            "state": "active",
+            "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon",
+            "web_url": "http://gdk.test:3001/root",
+        },
+    },
+    {
+        "id": 234,
+        "iid": 2,
+        "project_id": 1,
+        "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+        "ref": "new-pipeline",
+        "status": "failed",
+        "source": "push",
+        "created_at": "2016-08-11T11:28:34.085Z",
+        "updated_at": "2016-08-11T11:32:35.169Z",
+        "web_url": "https://example.com/foo/bar/pipelines/58",
+        "user": {
+            "id": 1,
+            "username": "root",
+            "name": "Administrator",
+            "state": "active",
+            "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon",
+            "web_url": "http://gdk.test:3001/root",
+        },
+    },
+]
+
+
+package_name = "hello-world"
+package_version = "v1.0.0"
+file_name = "hello.tar.gz"
+file_content = "package content"
+package_url = f"http://localhost/api/v4/projects/1/packages/generic/{package_name}/{package_version}/{file_name}"
+
+
+@pytest.fixture
+def resp_list_packages():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=re.compile(r"http://localhost/api/v4/(groups|projects)/1/packages"),
+            json=[package_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_package():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/packages/1",
+            json=package_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_delete_package():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/projects/1/packages/1",
+            status=204,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_delete_package_file():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/projects/1/packages/1/package_files/1",
+            status=204,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_delete_package_file_list():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=re.compile(
+                r"http://localhost/api/v4/projects/1/packages/1/package_files"
+            ),
+            json=package_file_content,
+            content_type="application/json",
+            status=200,
+        )
+        for pkg_file_id in range(25, 28):
+            rsps.add(
+                method=responses.DELETE,
+                url=f"http://localhost/api/v4/projects/1/packages/1/package_files/{pkg_file_id}",
+                status=204,
+            )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_package_files():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=re.compile(
+                r"http://localhost/api/v4/projects/1/packages/1/package_files"
+            ),
+            json=package_file_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_package_pipelines():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=re.compile(r"http://localhost/api/v4/projects/1/packages/1/pipelines"),
+            json=package_pipeline_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_upload_generic_package(created_content):
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.PUT,
+            url=package_url,
+            json=created_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_download_generic_package(created_content):
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=package_url,
+            body=file_content,
+            content_type="application/octet-stream",
+            status=200,
+        )
+        yield rsps
+
+
+def test_list_project_packages(project, resp_list_packages):
+    packages = project.packages.list()
+    assert isinstance(packages, list)
+    assert isinstance(packages[0], ProjectPackage)
+    assert packages[0].version == "1.0-SNAPSHOT"
+
+
+def test_list_group_packages(group, resp_list_packages):
+    packages = group.packages.list()
+    assert isinstance(packages, list)
+    assert isinstance(packages[0], GroupPackage)
+    assert packages[0].version == "1.0-SNAPSHOT"
+
+
+def test_get_project_package(project, resp_get_package):
+    package = project.packages.get(1)
+    assert isinstance(package, ProjectPackage)
+    assert package.version == "1.0-SNAPSHOT"
+
+
+def test_delete_project_package(project, resp_delete_package):
+    package = project.packages.get(1, lazy=True)
+    package.delete()
+
+
+def test_list_project_package_files(project, resp_list_package_files):
+    package = project.packages.get(1, lazy=True)
+    package_files = package.package_files.list()
+    assert isinstance(package_files, list)
+    assert isinstance(package_files[0], ProjectPackageFile)
+    assert package_files[0].id == 25
+
+
+def test_delete_project_package_file_from_package_object(
+    project, resp_delete_package_file
+):
+    package = project.packages.get(1, lazy=True)
+    package.package_files.delete(1)
+
+
+def test_delete_project_package_file_from_package_file_object(
+    project, resp_delete_package_file_list
+):
+    package = project.packages.get(1, lazy=True)
+    for package_file in package.package_files.list():
+        package_file.delete()
+
+
+def test_list_project_package_pipelines(project, resp_list_package_pipelines):
+    package = project.packages.get(1, lazy=True)
+    pipelines = package.pipelines.list()
+    assert isinstance(pipelines, list)
+    assert isinstance(pipelines[0], ProjectPackagePipeline)
+    assert pipelines[0].id == 123
+
+
+def test_upload_generic_package(tmp_path, project, resp_upload_generic_package):
+    path = tmp_path / file_name
+    path.write_text(file_content, encoding="utf-8")
+    package = project.generic_packages.upload(
+        package_name=package_name,
+        package_version=package_version,
+        file_name=file_name,
+        path=path,
+    )
+
+    assert isinstance(package, GenericPackage)
+
+
+def test_upload_generic_package_nonexistent_path(tmp_path, project):
+    with pytest.raises(exc.GitlabUploadError):
+        project.generic_packages.upload(
+            package_name=package_name,
+            package_version=package_version,
+            file_name=file_name,
+            path="bad",
+        )
+
+
+def test_upload_generic_package_no_file_and_no_data(tmp_path, project):
+    path = tmp_path / file_name
+
+    path.write_text(file_content, encoding="utf-8")
+
+    with pytest.raises(exc.GitlabUploadError):
+        project.generic_packages.upload(
+            package_name=package_name,
+            package_version=package_version,
+            file_name=file_name,
+        )
+
+
+def test_upload_generic_package_file_and_data(tmp_path, project):
+    path = tmp_path / file_name
+
+    path.write_text(file_content, encoding="utf-8")
+
+    with pytest.raises(exc.GitlabUploadError):
+        project.generic_packages.upload(
+            package_name=package_name,
+            package_version=package_version,
+            file_name=file_name,
+            path=path,
+            data=path.read_bytes(),
+        )
+
+
+def test_upload_generic_package_bytes(tmp_path, project, resp_upload_generic_package):
+    path = tmp_path / file_name
+
+    path.write_text(file_content, encoding="utf-8")
+
+    package = project.generic_packages.upload(
+        package_name=package_name,
+        package_version=package_version,
+        file_name=file_name,
+        data=path.read_bytes(),
+    )
+
+    assert isinstance(package, GenericPackage)
+
+
+def test_upload_generic_package_file(tmp_path, project, resp_upload_generic_package):
+    path = tmp_path / file_name
+
+    path.write_text(file_content, encoding="utf-8")
+
+    package = project.generic_packages.upload(
+        package_name=package_name,
+        package_version=package_version,
+        file_name=file_name,
+        data=path.open(mode="rb"),
+    )
+
+    assert isinstance(package, GenericPackage)
+
+
+def test_download_generic_package(project, resp_download_generic_package):
+    package = project.generic_packages.download(
+        package_name=package_name, package_version=package_version, file_name=file_name
+    )
+
+    assert isinstance(package, bytes)
diff --git a/tests/unit/objects/test_personal_access_tokens.py b/tests/unit/objects/test_personal_access_tokens.py
new file mode 100644
index 000000000..6272cecc1
--- /dev/null
+++ b/tests/unit/objects/test_personal_access_tokens.py
@@ -0,0 +1,180 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/personal_access_tokens.html
+https://docs.gitlab.com/ee/api/users.html#create-a-personal-access-token
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import PersonalAccessToken
+
+user_id = 1
+token_id = 1
+token_name = "Test Token"
+
+token_url = "http://localhost/api/v4/personal_access_tokens"
+single_token_url = f"{token_url}/{token_id}"
+self_token_url = f"{token_url}/self"
+user_token_url = f"http://localhost/api/v4/users/{user_id}/personal_access_tokens"
+
+content = {
+    "id": token_id,
+    "name": token_name,
+    "revoked": False,
+    "created_at": "2020-07-23T14:31:47.729Z",
+    "scopes": ["api"],
+    "active": True,
+    "user_id": user_id,
+    "expires_at": None,
+}
+
+
+@pytest.fixture
+def resp_create_user_personal_access_token():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url=user_token_url,
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_personal_access_tokens():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=token_url,
+            json=[content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_personal_access_token():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=single_token_url,
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_personal_access_token_self():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=self_token_url,
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_delete_personal_access_token():
+    with responses.RequestsMock() as rsps:
+        rsps.add(method=responses.DELETE, url=single_token_url, status=204)
+        yield rsps
+
+
+@pytest.fixture
+def resp_rotate_personal_access_token(token_content):
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/personal_access_tokens/1/rotate",
+            json=token_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_self_rotate_personal_access_token(token_content):
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/personal_access_tokens/self/rotate",
+            json=token_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_create_personal_access_token(gl, resp_create_user_personal_access_token):
+    user = gl.users.get(1, lazy=True)
+    access_token = user.personal_access_tokens.create(
+        {"name": token_name, "scopes": "api"}
+    )
+    assert access_token.revoked is False
+    assert access_token.name == token_name
+
+
+def test_list_personal_access_tokens(gl, resp_list_personal_access_tokens):
+    access_tokens = gl.personal_access_tokens.list()
+    assert len(access_tokens) == 1
+    assert access_tokens[0].revoked is False
+    assert access_tokens[0].name == token_name
+
+
+def test_list_personal_access_tokens_filter(gl, resp_list_personal_access_tokens):
+    access_tokens = gl.personal_access_tokens.list(user_id=user_id)
+    assert len(access_tokens) == 1
+    assert access_tokens[0].revoked is False
+    assert access_tokens[0].user_id == user_id
+
+
+def test_get_personal_access_token(gl, resp_get_personal_access_token):
+    access_token = gl.personal_access_tokens.get(token_id)
+
+    assert access_token.revoked is False
+    assert access_token.user_id == user_id
+
+
+def test_get_personal_access_token_self(gl, resp_get_personal_access_token_self):
+    access_token = gl.personal_access_tokens.get("self")
+
+    assert access_token.revoked is False
+    assert access_token.user_id == user_id
+
+
+def test_delete_personal_access_token(gl, resp_delete_personal_access_token):
+    access_token = gl.personal_access_tokens.get(token_id, lazy=True)
+    access_token.delete()
+
+
+def test_revoke_personal_access_token_by_id(gl, resp_delete_personal_access_token):
+    gl.personal_access_tokens.delete(token_id)
+
+
+def test_rotate_personal_access_token(gl, resp_rotate_personal_access_token):
+    access_token = gl.personal_access_tokens.get(1, lazy=True)
+    access_token.rotate()
+    assert isinstance(access_token, PersonalAccessToken)
+    assert access_token.token == "s3cr3t"
+
+
+def test_self_rotate_personal_access_token(gl, resp_self_rotate_personal_access_token):
+    access_token = gl.personal_access_tokens.get(1, lazy=True)
+    access_token.rotate(self_rotate=True)
+    assert isinstance(access_token, PersonalAccessToken)
+    assert access_token.token == "s3cr3t"
+
+    # Verify that the url contains "self"
+    rotation_calls = resp_self_rotate_personal_access_token.calls
+    assert len(rotation_calls) == 1
+    assert "self/rotate" in rotation_calls[0].request.url
diff --git a/tests/unit/objects/test_pipeline_schedules.py b/tests/unit/objects/test_pipeline_schedules.py
new file mode 100644
index 000000000..3a27becb1
--- /dev/null
+++ b/tests/unit/objects/test_pipeline_schedules.py
@@ -0,0 +1,108 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/pipeline_schedules.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import ProjectPipelineSchedulePipeline
+
+pipeline_content = {
+    "id": 48,
+    "iid": 13,
+    "project_id": 29,
+    "status": "pending",
+    "source": "scheduled",
+    "ref": "new-pipeline",
+    "sha": "eb94b618fb5865b26e80fdd8ae531b7a63ad851a",
+    "web_url": "https://example.com/foo/bar/pipelines/48",
+    "created_at": "2016-08-12T10:06:04.561Z",
+    "updated_at": "2016-08-12T10:09:56.223Z",
+}
+
+
+@pytest.fixture
+def resp_create_pipeline_schedule():
+    content = {
+        "id": 14,
+        "description": "Build packages",
+        "ref": "main",
+        "cron": "0 1 * * 5",
+        "cron_timezone": "UTC",
+        "next_run_at": "2017-05-26T01:00:00.000Z",
+        "active": True,
+        "created_at": "2017-05-19T13:43:08.169Z",
+        "updated_at": "2017-05-19T13:43:08.169Z",
+        "last_pipeline": None,
+        "owner": {
+            "name": "Administrator",
+            "username": "root",
+            "id": 1,
+            "state": "active",
+            "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+            "web_url": "https://gitlab.example.com/root",
+        },
+    }
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/pipeline_schedules",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_play_pipeline_schedule(created_content):
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/pipeline_schedules/1/play",
+            json=created_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_schedule_pipelines():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/pipeline_schedules/1/pipelines",
+            json=[pipeline_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_create_project_pipeline_schedule(project, resp_create_pipeline_schedule):
+    description = "Build packages"
+    cronline = "0 1 * * 5"
+    sched = project.pipelineschedules.create(
+        {"ref": "main", "description": description, "cron": cronline}
+    )
+    assert sched is not None
+    assert description == sched.description
+    assert cronline == sched.cron
+
+
+def test_play_project_pipeline_schedule(schedule, resp_play_pipeline_schedule):
+    play_result = schedule.play()
+    assert play_result is not None
+    assert "message" in play_result
+    assert play_result["message"] == "201 Created"
+
+
+def test_list_project_pipeline_schedule_pipelines(
+    schedule, resp_list_schedule_pipelines
+):
+    pipelines = schedule.pipelines.list()
+    assert isinstance(pipelines, list)
+    assert isinstance(pipelines[0], ProjectPipelineSchedulePipeline)
+    assert pipelines[0].source == "scheduled"
diff --git a/tests/unit/objects/test_pipelines.py b/tests/unit/objects/test_pipelines.py
new file mode 100644
index 000000000..79ee2657d
--- /dev/null
+++ b/tests/unit/objects/test_pipelines.py
@@ -0,0 +1,297 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/pipelines.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import (
+    ProjectPipeline,
+    ProjectPipelineTestReport,
+    ProjectPipelineTestReportSummary,
+)
+
+pipeline_content = {
+    "id": 46,
+    "project_id": 1,
+    "status": "pending",
+    "ref": "main",
+    "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+    "before_sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+    "tag": False,
+    "yaml_errors": None,
+    "user": {
+        "name": "Administrator",
+        "username": "root",
+        "id": 1,
+        "state": "active",
+        "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+        "web_url": "http://localhost:3000/root",
+    },
+    "created_at": "2016-08-11T11:28:34.085Z",
+    "updated_at": "2016-08-11T11:32:35.169Z",
+    "started_at": None,
+    "finished_at": "2016-08-11T11:32:35.145Z",
+    "committed_at": None,
+    "duration": None,
+    "queued_duration": 0.010,
+    "coverage": None,
+    "web_url": "https://example.com/foo/bar/pipelines/46",
+}
+
+pipeline_latest = {
+    "id": 47,
+    "project_id": 1,
+    "status": "pending",
+    "ref": "main",
+    "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+    "before_sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+    "tag": False,
+    "yaml_errors": None,
+    "user": {
+        "name": "Administrator",
+        "username": "root",
+        "id": 1,
+        "state": "active",
+        "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+        "web_url": "http://localhost:3000/root",
+    },
+    "created_at": "2016-08-11T11:28:34.085Z",
+    "updated_at": "2016-08-11T11:32:35.169Z",
+    "started_at": None,
+    "finished_at": "2016-08-11T11:32:35.145Z",
+    "committed_at": None,
+    "duration": None,
+    "queued_duration": 0.010,
+    "coverage": None,
+    "web_url": "https://example.com/foo/bar/pipelines/46",
+}
+
+pipeline_latest_other_ref = {
+    "id": 48,
+    "project_id": 1,
+    "status": "pending",
+    "ref": "feature-ref",
+    "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+    "before_sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+    "tag": False,
+    "yaml_errors": None,
+    "user": {
+        "name": "Administrator",
+        "username": "root",
+        "id": 1,
+        "state": "active",
+        "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+        "web_url": "http://localhost:3000/root",
+    },
+    "created_at": "2016-08-11T11:28:34.085Z",
+    "updated_at": "2016-08-11T11:32:35.169Z",
+    "started_at": None,
+    "finished_at": "2016-08-11T11:32:35.145Z",
+    "committed_at": None,
+    "duration": None,
+    "queued_duration": 0.010,
+    "coverage": None,
+    "web_url": "https://example.com/foo/bar/pipelines/46",
+}
+
+
+test_report_content = {
+    "total_time": 5,
+    "total_count": 1,
+    "success_count": 1,
+    "failed_count": 0,
+    "skipped_count": 0,
+    "error_count": 0,
+    "test_suites": [
+        {
+            "name": "Secure",
+            "total_time": 5,
+            "total_count": 1,
+            "success_count": 1,
+            "failed_count": 0,
+            "skipped_count": 0,
+            "error_count": 0,
+            "test_cases": [
+                {
+                    "status": "success",
+                    "name": "Security Reports can create an auto-remediation MR",
+                    "classname": "vulnerability_management_spec",
+                    "execution_time": 5,
+                    "system_output": None,
+                    "stack_trace": None,
+                }
+            ],
+        }
+    ],
+}
+
+
+test_report_summary_content = {
+    "total": {
+        "time": 1904,
+        "count": 3363,
+        "success": 3351,
+        "failed": 0,
+        "skipped": 12,
+        "error": 0,
+        "suite_error": None,
+    },
+    "test_suites": [
+        {
+            "name": "test",
+            "total_time": 1904,
+            "total_count": 3363,
+            "success_count": 3351,
+            "failed_count": 0,
+            "skipped_count": 12,
+            "error_count": 0,
+            "build_ids": [66004],
+            "suite_error": None,
+        }
+    ],
+}
+
+
+@pytest.fixture
+def resp_get_pipeline():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/pipelines/1",
+            json=pipeline_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_cancel_pipeline():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/pipelines/1/cancel",
+            json=pipeline_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_retry_pipeline():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/pipelines/1/retry",
+            json=pipeline_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_pipeline_test_report():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/pipelines/1/test_report",
+            json=test_report_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_pipeline_test_report_summary():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/pipelines/1/test_report_summary",
+            json=test_report_summary_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_latest():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/pipelines/latest",
+            json=pipeline_latest,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_latest_other_ref():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/pipelines/latest",
+            json=pipeline_latest_other_ref,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_get_project_pipeline(project, resp_get_pipeline):
+    pipeline = project.pipelines.get(1)
+    assert isinstance(pipeline, ProjectPipeline)
+    assert pipeline.ref == "main"
+    assert pipeline.id == 46
+
+
+def test_cancel_project_pipeline(project, resp_cancel_pipeline):
+    pipeline = project.pipelines.get(1, lazy=True)
+
+    output = pipeline.cancel()
+    assert output["ref"] == "main"
+
+
+def test_retry_project_pipeline(project, resp_retry_pipeline):
+    pipeline = project.pipelines.get(1, lazy=True)
+
+    output = pipeline.retry()
+    assert output["ref"] == "main"
+
+
+def test_get_project_pipeline_test_report(project, resp_get_pipeline_test_report):
+    pipeline = project.pipelines.get(1, lazy=True)
+    test_report = pipeline.test_report.get()
+    assert isinstance(test_report, ProjectPipelineTestReport)
+    assert test_report.total_time == 5
+    assert test_report.test_suites[0]["name"] == "Secure"
+
+
+def test_get_project_pipeline_test_report_summary(
+    project, resp_get_pipeline_test_report_summary
+):
+    pipeline = project.pipelines.get(1, lazy=True)
+    test_report_summary = pipeline.test_report_summary.get()
+    assert isinstance(test_report_summary, ProjectPipelineTestReportSummary)
+    assert test_report_summary.total["count"] == 3363
+    assert test_report_summary.test_suites[0]["name"] == "test"
+
+
+def test_latest_pipeline(project, resp_get_latest):
+    pipeline = project.pipelines.latest()
+    assert isinstance(pipeline, ProjectPipeline)
+    assert pipeline.ref == "main"
+    assert pipeline.id == 47
+
+
+def test_latest_pipeline_other_ref(project, resp_get_latest_other_ref):
+    pipeline = project.pipelines.latest(ref="feature-ref")
+    assert isinstance(pipeline, ProjectPipeline)
+    assert pipeline.ref == "feature-ref"
+    assert pipeline.id == 48
diff --git a/tests/unit/objects/test_project_access_tokens.py b/tests/unit/objects/test_project_access_tokens.py
new file mode 100644
index 000000000..77b5108fe
--- /dev/null
+++ b/tests/unit/objects/test_project_access_tokens.py
@@ -0,0 +1,156 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/project_access_tokens.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import ProjectAccessToken
+
+
+@pytest.fixture
+def resp_list_project_access_token(token_content):
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/access_tokens",
+            json=[token_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_project_access_token(token_content):
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/access_tokens/1",
+            json=token_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_create_project_access_token(token_content):
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/access_tokens",
+            json=token_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_revoke_project_access_token():
+    content = [
+        {
+            "user_id": 141,
+            "scopes": ["api"],
+            "name": "token",
+            "expires_at": "2021-01-31",
+            "id": 42,
+            "active": True,
+            "created_at": "2021-01-20T22:11:48.151Z",
+            "revoked": False,
+        }
+    ]
+
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/projects/1/access_tokens/42",
+            status=204,
+        )
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/access_tokens",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_rotate_project_access_token(token_content):
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/access_tokens/1/rotate",
+            json=token_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_self_rotate_project_access_token(token_content):
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/access_tokens/self/rotate",
+            json=token_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_list_project_access_tokens(gl, resp_list_project_access_token):
+    access_tokens = gl.projects.get(1, lazy=True).access_tokens.list()
+    assert len(access_tokens) == 1
+    assert access_tokens[0].revoked is False
+    assert access_tokens[0].name == "token"
+
+
+def test_get_project_access_token(project, resp_get_project_access_token):
+    access_token = project.access_tokens.get(1)
+    assert isinstance(access_token, ProjectAccessToken)
+    assert access_token.revoked is False
+    assert access_token.name == "token"
+
+
+def test_create_project_access_token(gl, resp_create_project_access_token):
+    access_tokens = gl.projects.get(1, lazy=True).access_tokens.create(
+        {"name": "test", "scopes": ["api"]}
+    )
+    assert access_tokens.revoked is False
+    assert access_tokens.user_id == 141
+    assert access_tokens.expires_at == "2021-01-31"
+
+
+def test_revoke_project_access_token(
+    gl, resp_list_project_access_token, resp_revoke_project_access_token
+):
+    gl.projects.get(1, lazy=True).access_tokens.delete(42)
+    access_token = gl.projects.get(1, lazy=True).access_tokens.list()[0]
+    access_token.delete()
+
+
+def test_rotate_project_access_token(project, resp_rotate_project_access_token):
+    access_token = project.access_tokens.get(1, lazy=True)
+    access_token.rotate()
+    assert isinstance(access_token, ProjectAccessToken)
+    assert access_token.token == "s3cr3t"
+
+
+def test_self_rotate_project_access_token(
+    project, resp_self_rotate_project_access_token
+):
+    access_token = project.access_tokens.get(1, lazy=True)
+    access_token.rotate(self_rotate=True)
+    assert isinstance(access_token, ProjectAccessToken)
+    assert access_token.token == "s3cr3t"
+
+    # Verify that the url contains "self"
+    rotation_calls = resp_self_rotate_project_access_token.calls
+    assert len(rotation_calls) == 1
+    assert "self/rotate" in rotation_calls[0].request.url
diff --git a/tests/unit/objects/test_project_import_export.py b/tests/unit/objects/test_project_import_export.py
new file mode 100644
index 000000000..251cdcfb6
--- /dev/null
+++ b/tests/unit/objects/test_project_import_export.py
@@ -0,0 +1,224 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/project_import_export.html
+"""
+
+import pytest
+import responses
+
+
+@pytest.fixture
+def resp_import_project():
+    content = {
+        "id": 1,
+        "description": None,
+        "name": "api-project",
+        "name_with_namespace": "Administrator / api-project",
+        "path": "api-project",
+        "path_with_namespace": "root/api-project",
+        "created_at": "2018-02-13T09:05:58.023Z",
+        "import_status": "scheduled",
+    }
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/import",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_remote_import():
+    content = {
+        "id": 1,
+        "description": None,
+        "name": "remote-project",
+        "name_with_namespace": "Administrator / remote-project",
+        "path": "remote-project",
+        "path_with_namespace": "root/remote-project",
+        "created_at": "2018-02-13T09:05:58.023Z",
+        "import_status": "scheduled",
+    }
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/remote-import",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_remote_import_s3():
+    content = {
+        "id": 1,
+        "description": None,
+        "name": "remote-project-s3",
+        "name_with_namespace": "Administrator / remote-project-s3",
+        "path": "remote-project-s3",
+        "path_with_namespace": "root/remote-project-s3",
+        "created_at": "2018-02-13T09:05:58.023Z",
+        "import_status": "scheduled",
+    }
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/remote-import-s3",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_import_status():
+    content = {
+        "id": 1,
+        "description": "Itaque perspiciatis minima aspernatur corporis consequatur.",
+        "name": "Gitlab Test",
+        "name_with_namespace": "Gitlab Org / Gitlab Test",
+        "path": "gitlab-test",
+        "path_with_namespace": "gitlab-org/gitlab-test",
+        "created_at": "2017-08-29T04:36:44.383Z",
+        "import_status": "finished",
+    }
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/import",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_import_github():
+    content = {
+        "id": 27,
+        "name": "my-repo",
+        "full_path": "/root/my-repo",
+        "full_name": "Administrator / my-repo",
+    }
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/import/github",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_import_bitbucket_server():
+    content = {"id": 1, "name": "project", "import_status": "scheduled"}
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/import/bitbucket_server",
+            json=content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+def test_import_project(gl, resp_import_project):
+    project_import = gl.projects.import_project(
+        "file", "api-project", "api-project", "root"
+    )
+    assert project_import["import_status"] == "scheduled"
+
+
+def test_remote_import(gl, resp_remote_import):
+    project_import = gl.projects.remote_import(
+        "https://whatever.com/url/file.tar.gz",
+        "remote-project",
+        "remote-project",
+        "root",
+    )
+    assert project_import["import_status"] == "scheduled"
+
+
+def test_remote_import_s3(gl, resp_remote_import_s3):
+    project_import = gl.projects.remote_import_s3(
+        "remote-project",
+        "aws-region",
+        "aws-bucket-name",
+        "aws-file-key",
+        "aws-access-key-id",
+        "secret-access-key",
+        "remote-project",
+        "root",
+    )
+    assert project_import["import_status"] == "scheduled"
+
+
+def test_import_project_with_override_params(gl, resp_import_project):
+    project_import = gl.projects.import_project(
+        "file", "api-project", override_params={"visibility": "private"}
+    )
+    assert project_import["import_status"] == "scheduled"
+
+
+def test_refresh_project_import_status(project, resp_import_status):
+    project_import = project.imports.get()
+    project_import.refresh()
+    assert project_import.import_status == "finished"
+
+
+def test_import_github(gl, resp_import_github):
+    base_path = "/root"
+    name = "my-repo"
+    ret = gl.projects.import_github("githubkey", 1234, base_path, name)
+    assert isinstance(ret, dict)
+    assert ret["name"] == name
+    assert ret["full_path"] == "/".join((base_path, name))
+    assert ret["full_name"].endswith(name)
+
+
+def test_import_bitbucket_server(gl, resp_import_bitbucket_server):
+    res = gl.projects.import_bitbucket_server(
+        bitbucket_server_project="project",
+        bitbucket_server_repo="repo",
+        bitbucket_server_url="url",
+        bitbucket_server_username="username",
+        personal_access_token="token",
+        new_name="new_name",
+        target_namespace="namespace",
+    )
+    assert res["id"] == 1
+    assert res["name"] == "project"
+    assert res["import_status"] == "scheduled"
+
+
+def test_create_project_export(project, resp_export):
+    export = project.exports.create()
+    assert export.message == "202 Accepted"
+
+
+def test_refresh_project_export_status(project, resp_export):
+    export = project.exports.create()
+    export.refresh()
+    assert export.export_status == "finished"
+
+
+def test_download_project_export(project, resp_export, binary_content):
+    export = project.exports.create()
+    download = export.download()
+    assert isinstance(download, bytes)
+    assert download == binary_content
diff --git a/tests/unit/objects/test_project_merge_request_approvals.py b/tests/unit/objects/test_project_merge_request_approvals.py
new file mode 100644
index 000000000..27cf48945
--- /dev/null
+++ b/tests/unit/objects/test_project_merge_request_approvals.py
@@ -0,0 +1,415 @@
+"""
+Gitlab API: https://docs.gitlab.com/ee/api/merge_request_approvals.html
+"""
+
+import copy
+
+import pytest
+import responses
+
+import gitlab
+from gitlab.mixins import UpdateMethod
+
+approval_rule_id = 7
+approval_rule_name = "security"
+approvals_required = 3
+user_ids = [5, 50]
+group_ids = [5]
+
+new_approval_rule_name = "new approval rule"
+new_approval_rule_user_ids = user_ids
+new_approval_rule_approvals_required = 2
+
+updated_approval_rule_user_ids = [5]
+updated_approval_rule_approvals_required = 1
+
+
+@pytest.fixture
+def resp_prj_approval_rules():
+    prj_ars_content = [
+        {
+            "id": approval_rule_id,
+            "name": approval_rule_name,
+            "rule_type": "regular",
+            "report_type": None,
+            "eligible_approvers": [
+                {
+                    "id": user_ids[0],
+                    "name": "John Doe",
+                    "username": "jdoe",
+                    "state": "active",
+                    "avatar_url": "https://www.gravatar.com/avatar/0?s=80&d=identicon",
+                    "web_url": "http://localhost/jdoe",
+                },
+                {
+                    "id": user_ids[1],
+                    "name": "Group Member 1",
+                    "username": "group_member_1",
+                    "state": "active",
+                    "avatar_url": "https://www.gravatar.com/avatar/0?s=80&d=identicon",
+                    "web_url": "http://localhost/group_member_1",
+                },
+            ],
+            "approvals_required": approvals_required,
+            "users": [
+                {
+                    "id": 5,
+                    "name": "John Doe",
+                    "username": "jdoe",
+                    "state": "active",
+                    "avatar_url": "https://www.gravatar.com/avatar/0?s=80&d=identicon",
+                    "web_url": "http://localhost/jdoe",
+                }
+            ],
+            "groups": [
+                {
+                    "id": 5,
+                    "name": "group1",
+                    "path": "group1",
+                    "description": "",
+                    "visibility": "public",
+                    "lfs_enabled": False,
+                    "avatar_url": None,
+                    "web_url": "http://localhost/groups/group1",
+                    "request_access_enabled": False,
+                    "full_name": "group1",
+                    "full_path": "group1",
+                    "parent_id": None,
+                    "ldap_cn": None,
+                    "ldap_access": None,
+                }
+            ],
+            "applies_to_all_protected_branches": False,
+            "protected_branches": [
+                {
+                    "id": 1,
+                    "name": "main",
+                    "push_access_levels": [
+                        {
+                            "access_level": 30,
+                            "access_level_description": "Developers + Maintainers",
+                        }
+                    ],
+                    "merge_access_levels": [
+                        {
+                            "access_level": 30,
+                            "access_level_description": "Developers + Maintainers",
+                        }
+                    ],
+                    "unprotect_access_levels": [
+                        {"access_level": 40, "access_level_description": "Maintainers"}
+                    ],
+                    "code_owner_approval_required": "false",
+                }
+            ],
+            "contains_hidden_groups": False,
+        }
+    ]
+
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/approval_rules",
+            json=prj_ars_content,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/approval_rules/7",
+            json=prj_ars_content[0],
+            content_type="application/json",
+            status=200,
+        )
+
+        new_prj_ars_content = dict(prj_ars_content[0])
+        new_prj_ars_content["name"] = new_approval_rule_name
+        new_prj_ars_content["approvals_required"] = new_approval_rule_approvals_required
+
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/approval_rules",
+            json=new_prj_ars_content,
+            content_type="application/json",
+            status=200,
+        )
+
+        updated_mr_ars_content = copy.deepcopy(prj_ars_content[0])
+        updated_mr_ars_content["eligible_approvers"] = [
+            prj_ars_content[0]["eligible_approvers"][0]
+        ]
+
+        updated_mr_ars_content["approvals_required"] = (
+            updated_approval_rule_approvals_required
+        )
+
+        rsps.add(
+            method=responses.PUT,
+            url="http://localhost/api/v4/projects/1/approval_rules/7",
+            json=updated_mr_ars_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_mr_approval_rules():
+    mr_ars_content = [
+        {
+            "id": approval_rule_id,
+            "name": approval_rule_name,
+            "rule_type": "regular",
+            "eligible_approvers": [
+                {
+                    "id": user_ids[0],
+                    "name": "John Doe",
+                    "username": "jdoe",
+                    "state": "active",
+                    "avatar_url": "https://www.gravatar.com/avatar/0?s=80&d=identicon",
+                    "web_url": "http://localhost/jdoe",
+                },
+                {
+                    "id": user_ids[1],
+                    "name": "Group Member 1",
+                    "username": "group_member_1",
+                    "state": "active",
+                    "avatar_url": "https://www.gravatar.com/avatar/0?s=80&d=identicon",
+                    "web_url": "http://localhost/group_member_1",
+                },
+            ],
+            "approvals_required": approvals_required,
+            "source_rule": None,
+            "users": [
+                {
+                    "id": 5,
+                    "name": "John Doe",
+                    "username": "jdoe",
+                    "state": "active",
+                    "avatar_url": "https://www.gravatar.com/avatar/0?s=80&d=identicon",
+                    "web_url": "http://localhost/jdoe",
+                }
+            ],
+            "groups": [
+                {
+                    "id": 5,
+                    "name": "group1",
+                    "path": "group1",
+                    "description": "",
+                    "visibility": "public",
+                    "lfs_enabled": False,
+                    "avatar_url": None,
+                    "web_url": "http://localhost/groups/group1",
+                    "request_access_enabled": False,
+                    "full_name": "group1",
+                    "full_path": "group1",
+                    "parent_id": None,
+                    "ldap_cn": None,
+                    "ldap_access": None,
+                }
+            ],
+            "contains_hidden_groups": False,
+            "overridden": False,
+        }
+    ]
+
+    approval_state_rules = copy.deepcopy(mr_ars_content)
+    approval_state_rules[0]["approved"] = False
+    approval_state_rules[0]["approved_by"] = []
+
+    mr_approval_state_content = {
+        "approval_rules_overwritten": False,
+        "rules": approval_state_rules,
+    }
+
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/merge_requests/3/approval_rules",
+            json=mr_ars_content,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/merge_requests/3/approval_rules/7",
+            json=mr_ars_content[0],
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/merge_requests/3/approval_state",
+            json=mr_approval_state_content,
+            content_type="application/json",
+            status=200,
+        )
+
+        new_mr_ars_content = dict(mr_ars_content[0])
+        new_mr_ars_content["name"] = new_approval_rule_name
+        new_mr_ars_content["approvals_required"] = new_approval_rule_approvals_required
+
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/merge_requests/3/approval_rules",
+            json=new_mr_ars_content,
+            content_type="application/json",
+            status=200,
+        )
+
+        updated_mr_ars_content = copy.deepcopy(mr_ars_content[0])
+        updated_mr_ars_content["eligible_approvers"] = [
+            mr_ars_content[0]["eligible_approvers"][0]
+        ]
+
+        updated_mr_ars_content["approvals_required"] = (
+            updated_approval_rule_approvals_required
+        )
+
+        rsps.add(
+            method=responses.PUT,
+            url="http://localhost/api/v4/projects/1/merge_requests/3/approval_rules/7",
+            json=updated_mr_ars_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_delete_mr_approval_rule():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/projects/1/merge_requests/3/approval_rules/7",
+            status=204,
+        )
+        yield rsps
+
+
+def test_project_approval_manager_update_method_post(project):
+    """Ensure the
+    gitlab.v4.objects.merge_request_approvals.ProjectApprovalManager object has
+    _update_method set to UpdateMethod.POST"""
+    approvals = project.approvals
+    assert isinstance(
+        approvals, gitlab.v4.objects.merge_request_approvals.ProjectApprovalManager
+    )
+    assert approvals._update_method is UpdateMethod.POST
+
+
+def test_list_project_approval_rules(project, resp_prj_approval_rules):
+    approval_rules = project.approvalrules.list()
+    assert len(approval_rules) == 1
+    assert approval_rules[0].name == approval_rule_name
+    assert approval_rules[0].id == approval_rule_id
+    assert (
+        repr(approval_rules[0])
+        == f"<ProjectApprovalRule id:{approval_rule_id} name:{approval_rule_name}>"
+    )
+
+
+def test_list_merge_request_approval_rules(project, resp_mr_approval_rules):
+    approval_rules = project.mergerequests.get(3, lazy=True).approval_rules.list()
+    assert len(approval_rules) == 1
+    assert approval_rules[0].name == approval_rule_name
+    assert approval_rules[0].id == approval_rule_id
+    repr(approval_rules)  # ensure that `repr()` doesn't raise an exception
+
+
+def test_delete_merge_request_approval_rule(project, resp_delete_mr_approval_rule):
+    merge_request = project.mergerequests.get(3, lazy=True)
+    merge_request.approval_rules.delete(approval_rule_id)
+
+
+def test_update_merge_request_approvals_set_approvers(project, resp_mr_approval_rules):
+    approvals = project.mergerequests.get(3, lazy=True).approvals
+    assert isinstance(
+        approvals,
+        gitlab.v4.objects.merge_request_approvals.ProjectMergeRequestApprovalManager,
+    )
+    assert approvals._update_method is UpdateMethod.POST
+    response = approvals.set_approvers(
+        updated_approval_rule_approvals_required,
+        approver_ids=updated_approval_rule_user_ids,
+        approver_group_ids=group_ids,
+        approval_rule_name=approval_rule_name,
+    )
+
+    assert response.approvals_required == updated_approval_rule_approvals_required
+    assert len(response.eligible_approvers) == len(updated_approval_rule_user_ids)
+    assert response.eligible_approvers[0]["id"] == updated_approval_rule_user_ids[0]
+    assert response.name == approval_rule_name
+
+
+def test_create_merge_request_approvals_set_approvers(project, resp_mr_approval_rules):
+    approvals = project.mergerequests.get(3, lazy=True).approvals
+    assert isinstance(
+        approvals,
+        gitlab.v4.objects.merge_request_approvals.ProjectMergeRequestApprovalManager,
+    )
+    assert approvals._update_method is UpdateMethod.POST
+    response = approvals.set_approvers(
+        new_approval_rule_approvals_required,
+        approver_ids=new_approval_rule_user_ids,
+        approver_group_ids=group_ids,
+        approval_rule_name=new_approval_rule_name,
+    )
+    assert response.approvals_required == new_approval_rule_approvals_required
+    assert len(response.eligible_approvers) == len(new_approval_rule_user_ids)
+    assert response.eligible_approvers[0]["id"] == new_approval_rule_user_ids[0]
+    assert response.name == new_approval_rule_name
+
+
+def test_create_merge_request_approval_rule(project, resp_mr_approval_rules):
+    approval_rules = project.mergerequests.get(3, lazy=True).approval_rules
+    data = {
+        "name": new_approval_rule_name,
+        "approvals_required": new_approval_rule_approvals_required,
+        "rule_type": "regular",
+        "user_ids": new_approval_rule_user_ids,
+        "group_ids": group_ids,
+    }
+    response = approval_rules.create(data)
+    assert response.approvals_required == new_approval_rule_approvals_required
+    assert len(response.eligible_approvers) == len(new_approval_rule_user_ids)
+    assert response.eligible_approvers[0]["id"] == new_approval_rule_user_ids[0]
+    assert response.name == new_approval_rule_name
+
+
+def test_update_merge_request_approval_rule(project, resp_mr_approval_rules):
+    approval_rules = project.mergerequests.get(3, lazy=True).approval_rules
+    ar_1 = approval_rules.list()[0]
+    ar_1.user_ids = updated_approval_rule_user_ids
+    ar_1.approvals_required = updated_approval_rule_approvals_required
+    ar_1.save()
+
+    assert ar_1.approvals_required == updated_approval_rule_approvals_required
+    assert len(ar_1.eligible_approvers) == len(updated_approval_rule_user_ids)
+    assert ar_1.eligible_approvers[0]["id"] == updated_approval_rule_user_ids[0]
+
+
+def test_get_merge_request_approval_rule(project, resp_mr_approval_rules):
+    merge_request = project.mergerequests.get(3, lazy=True)
+    approval_rule = merge_request.approval_rules.get(approval_rule_id)
+    assert isinstance(
+        approval_rule,
+        gitlab.v4.objects.merge_request_approvals.ProjectMergeRequestApprovalRule,
+    )
+    assert approval_rule.name == approval_rule_name
+    assert approval_rule.id == approval_rule_id
+
+
+def test_get_merge_request_approval_state(project, resp_mr_approval_rules):
+    merge_request = project.mergerequests.get(3, lazy=True)
+    approval_state = merge_request.approval_state.get()
+    assert isinstance(
+        approval_state,
+        gitlab.v4.objects.merge_request_approvals.ProjectMergeRequestApprovalState,
+    )
+    assert not approval_state.approval_rules_overwritten
+    assert len(approval_state.rules) == 1
+    assert approval_state.rules[0]["name"] == approval_rule_name
+    assert approval_state.rules[0]["id"] == approval_rule_id
+    assert not approval_state.rules[0]["approved"]
+    assert approval_state.rules[0]["approved_by"] == []
diff --git a/tests/unit/objects/test_project_statistics.py b/tests/unit/objects/test_project_statistics.py
new file mode 100644
index 000000000..2644102ab
--- /dev/null
+++ b/tests/unit/objects/test_project_statistics.py
@@ -0,0 +1,29 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/project_statistics.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import ProjectAdditionalStatistics
+
+
+@pytest.fixture
+def resp_project_statistics():
+    content = {"fetches": {"total": 50, "days": [{"count": 10, "date": "2018-01-10"}]}}
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/statistics",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_project_additional_statistics(project, resp_project_statistics):
+    statistics = project.additionalstatistics.get()
+    assert isinstance(statistics, ProjectAdditionalStatistics)
+    assert statistics.fetches["total"] == 50
diff --git a/tests/unit/objects/test_projects.py b/tests/unit/objects/test_projects.py
new file mode 100644
index 000000000..5325b2bc5
--- /dev/null
+++ b/tests/unit/objects/test_projects.py
@@ -0,0 +1,763 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/projects.html
+"""
+
+from unittest.mock import mock_open, patch
+
+import pytest
+import responses
+
+from gitlab import exceptions
+from gitlab.const import DEVELOPER_ACCESS, SEARCH_SCOPE_ISSUES
+from gitlab.v4.objects import (
+    Project,
+    ProjectFork,
+    ProjectUser,
+    StarredProject,
+    UserProject,
+)
+from gitlab.v4.objects.projects import ProjectStorage
+
+project_content = {"name": "name", "id": 1}
+project_with_owner_content = {
+    "name": "name",
+    "id": 1,
+    "owner": {"id": 1, "username": "owner_username", "name": "owner_name"},
+}
+languages_content = {"python": 80.00, "ruby": 99.99, "CoffeeScript": 0.01}
+user_content = {"name": "first", "id": 1, "state": "active"}
+forks_content = [{"id": 1}]
+project_forked_from_content = {
+    "name": "name",
+    "id": 2,
+    "forks_count": 0,
+    "forked_from_project": {"id": 1, "name": "name", "forks_count": 1},
+}
+project_starrers_content = {
+    "starred_since": "2019-01-28T14:47:30.642Z",
+    "user": {"id": 1, "name": "name"},
+}
+upload_file_content = {
+    "alt": "filename",
+    "url": "/uploads/66dbcd21ec5d24ed6ea225176098d52b/filename.png",
+    "full_path": "/namespace/project/uploads/66dbcd21ec5d24ed6ea225176098d52b/filename.png",
+    "markdown": "![dk](/uploads/66dbcd21ec5d24ed6ea225176098d52b/filename.png)",
+}
+share_project_content = {
+    "id": 1,
+    "project_id": 1,
+    "group_id": 1,
+    "group_access": 30,
+    "expires_at": None,
+}
+push_rules_content = {"id": 1, "deny_delete_tag": True}
+search_issues_content = [{"id": 1, "iid": 1, "project_id": 1, "title": "Issue"}]
+pipeline_trigger_content = {
+    "id": 1,
+    "iid": 1,
+    "project_id": 1,
+    "ref": "main",
+    "status": "created",
+    "source": "trigger",
+}
+
+
+@pytest.fixture
+def resp_get_project():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1",
+            json=project_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_create_project():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects",
+            json=project_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_create_user_project():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/user/1",
+            json=project_with_owner_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_fork_project():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/fork",
+            json=project_forked_from_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_update_project():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.PUT,
+            url="http://localhost/api/v4/projects/1",
+            json=project_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_project_storage():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/storage",
+            json={"project_id": 1, "disk_path": "/disk/path"},
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_user_projects():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/users/1/projects",
+            json=[project_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_star_project():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/star",
+            json=project_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_unstar_project():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/unstar",
+            json=project_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_project_starrers():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/starrers",
+            json=[project_starrers_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_starred_projects():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/users/1/starred_projects",
+            json=[project_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_users():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/users",
+            json=[user_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_forks():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/forks",
+            json=forks_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_languages():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/languages",
+            json=languages_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_projects():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects",
+            json=[project_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_transfer_project():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.PUT,
+            url="http://localhost/api/v4/projects/1/transfer",
+            json=project_content,
+            content_type="application/json",
+            status=200,
+            match=[
+                responses.matchers.json_params_matcher({"namespace": "test-namespace"})
+            ],
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_archive_project():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/archive",
+            json=project_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_unarchive_project():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/unarchive",
+            json=project_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_delete_project(accepted_content):
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/projects/1",
+            json=accepted_content,
+            content_type="application/json",
+            status=202,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_upload_file_project():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/uploads",
+            json=upload_file_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_share_project():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/share",
+            json=share_project_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_unshare_project():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/projects/1/share/1",
+            status=204,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_create_fork_relation():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/2/fork/1",
+            json=project_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_delete_fork_relation():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/projects/2/fork",
+            status=204,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_trigger_pipeline():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/trigger/pipeline",
+            json=pipeline_trigger_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_search_project_resources_by_name():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/search?scope=issues&search=Issue",
+            json=search_issues_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_start_housekeeping():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/housekeeping",
+            json={},
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_push_rules_project():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/push_rule",
+            json=push_rules_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_create_push_rules_project():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/push_rule",
+            json=push_rules_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_update_push_rules_project():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/push_rule",
+            json=push_rules_content,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.PUT,
+            url="http://localhost/api/v4/projects/1/push_rule",
+            json=push_rules_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_delete_push_rules_project():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/push_rule",
+            json=push_rules_content,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/projects/1/push_rule",
+            status=204,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_restore_project(created_content):
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/restore",
+            json=created_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_start_pull_mirroring_project():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/mirror/pull",
+            json={},
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_pull_mirror_details_project():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/mirror/pull",
+            json={
+                "id": 101486,
+                "last_error": None,
+                "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",
+                "update_status": "finished",
+                "url": "https://*****:*****@gitlab.com/gitlab-org/security/gitlab.git",
+            },
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_snapshot_project():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/snapshot",
+            content_type="application/x-tar",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_artifact():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/jobs/artifacts/ref_name/raw/artifact_path?job=job",
+            content_type="application/x-tar",
+            status=200,
+        )
+        yield rsps
+
+
+def test_get_project(gl, resp_get_project):
+    data = gl.projects.get(1)
+    assert isinstance(data, Project)
+    assert data.name == "name"
+    assert data.id == 1
+
+
+def test_list_projects(gl, resp_list_projects):
+    projects = gl.projects.list()
+    assert isinstance(projects[0], Project)
+    assert projects[0].name == "name"
+
+
+def test_list_user_projects(user, resp_list_user_projects):
+    user_project = user.projects.list()[0]
+    assert isinstance(user_project, UserProject)
+    assert user_project.name == "name"
+    assert user_project.id == 1
+
+
+def test_list_user_starred_projects(user, resp_list_starred_projects):
+    starred_projects = user.starred_projects.list()[0]
+    assert isinstance(starred_projects, StarredProject)
+    assert starred_projects.name == "name"
+    assert starred_projects.id == 1
+
+
+def test_list_project_users(project, resp_list_users):
+    user = project.users.list()[0]
+    assert isinstance(user, ProjectUser)
+    assert user.id == 1
+    assert user.name == "first"
+    assert user.state == "active"
+
+
+def test_create_project(gl, resp_create_project):
+    project = gl.projects.create({"name": "name"})
+    assert project.id == 1
+    assert project.name == "name"
+
+
+def test_create_user_project(user, resp_create_user_project):
+    user_project = user.projects.create({"name": "name"})
+    assert user_project.id == 1
+    assert user_project.name == "name"
+    assert user_project.owner
+    assert user_project.owner.get("id") == user.id
+    assert user_project.owner.get("name") == "owner_name"
+    assert user_project.owner.get("username") == "owner_username"
+
+
+def test_update_project(project, resp_update_project):
+    project.snippets_enabled = 1
+    project.save()
+
+
+def test_fork_project(project, resp_fork_project):
+    fork = project.forks.create({})
+    assert fork.id == 2
+    assert fork.name == "name"
+    assert fork.forks_count == 0
+    assert fork.forked_from_project
+    assert fork.forked_from_project.get("id") == project.id
+    assert fork.forked_from_project.get("name") == "name"
+    assert fork.forked_from_project.get("forks_count") == 1
+
+
+def test_list_project_forks(project, resp_list_forks):
+    fork = project.forks.list()[0]
+    assert isinstance(fork, ProjectFork)
+    assert fork.id == 1
+
+
+def test_star_project(project, resp_star_project):
+    project.star()
+
+
+def test_unstar_project(project, resp_unstar_project):
+    project.unstar()
+
+
+@pytest.mark.skip(reason="missing test")
+def test_list_project_starrers(project, resp_list_project_starrers):
+    pass
+
+
+def test_get_project_languages(project, resp_list_languages):
+    python = project.languages().get("python")
+    ruby = project.languages().get("ruby")
+    coffee_script = project.languages().get("CoffeeScript")
+    assert python == 80.00
+    assert ruby == 99.99
+    assert coffee_script == 00.01
+
+
+def test_get_project_storage(project, resp_get_project_storage):
+    storage = project.storage.get()
+    assert isinstance(storage, ProjectStorage)
+    assert storage.disk_path == "/disk/path"
+
+
+def test_archive_project(project, resp_archive_project):
+    project.archive()
+
+
+def test_unarchive_project(project, resp_unarchive_project):
+    project.unarchive()
+
+
+def test_delete_project(project, resp_delete_project):
+    project.delete()
+
+
+def test_upload_file(project, resp_upload_file_project):
+    project.upload("filename.png", "raw\nfile\ndata")
+
+
+def test_upload_file_with_filepath(project, resp_upload_file_project):
+    with patch("builtins.open", mock_open(read_data="raw\nfile\ndata")):
+        project.upload("filename.png", None, "/filepath")
+
+
+def test_upload_file_without_filepath_nor_filedata(project):
+    with pytest.raises(
+        exceptions.GitlabUploadError, match="No file contents or path specified"
+    ):
+        project.upload("filename.png")
+
+
+def test_upload_file_with_filepath_and_filedata(project):
+    with pytest.raises(
+        exceptions.GitlabUploadError, match="File contents and file path specified"
+    ):
+        project.upload("filename.png", "filedata", "/filepath")
+
+
+def test_share_project(project, group, resp_share_project):
+    project.share(group.id, DEVELOPER_ACCESS)
+
+
+def test_delete_shared_project_link(project, group, resp_unshare_project):
+    project.unshare(group.id)
+
+
+def test_trigger_pipeline_project(project, resp_trigger_pipeline):
+    project.trigger_pipeline("MOCK_PIPELINE_TRIGGER_TOKEN", "main")
+
+
+def test_create_forked_from_relationship(
+    project, another_project, resp_create_fork_relation
+):
+    another_project.create_fork_relation(project.id)
+
+
+def test_delete_forked_from_relationship(another_project, resp_delete_fork_relation):
+    another_project.delete_fork_relation()
+
+
+def test_search_project_resources_by_name(
+    project, resp_search_project_resources_by_name
+):
+    issue = project.search(SEARCH_SCOPE_ISSUES, "Issue")[0]
+    assert issue
+    assert issue.get("title") == "Issue"
+
+
+def test_project_housekeeping(project, resp_start_housekeeping):
+    project.housekeeping()
+
+
+def test_list_project_push_rules(project, resp_list_push_rules_project):
+    pr = project.pushrules.get()
+    assert pr
+    assert pr.deny_delete_tag
+
+
+def test_create_project_push_rule(project, resp_create_push_rules_project):
+    project.pushrules.create({"deny_delete_tag": True})
+
+
+def test_update_project_push_rule(project, resp_update_push_rules_project):
+    pr = project.pushrules.get()
+    pr.deny_delete_tag = False
+    pr.save()
+
+
+def test_delete_project_push_rule(project, resp_delete_push_rules_project):
+    pr = project.pushrules.get()
+    pr.delete()
+
+
+def test_transfer_project(project, resp_transfer_project):
+    project.transfer("test-namespace")
+
+
+def test_project_pull_mirror(project, resp_start_pull_mirroring_project):
+    with pytest.warns(DeprecationWarning, match="is deprecated"):
+        project.mirror_pull()
+
+
+def test_project_pull_mirror_details(project, resp_pull_mirror_details_project):
+    with pytest.warns(DeprecationWarning, match="is deprecated"):
+        details = project.mirror_pull_details()
+    assert details["last_error"] is None
+    assert details["update_status"] == "finished"
+
+
+def test_project_restore(project, resp_restore_project):
+    project.restore()
+
+
+def test_project_snapshot(project, resp_snapshot_project):
+    tar_file = project.snapshot()
+    assert isinstance(tar_file, bytes)
diff --git a/tests/unit/objects/test_pull_mirror.py b/tests/unit/objects/test_pull_mirror.py
new file mode 100644
index 000000000..3fa671bc2
--- /dev/null
+++ b/tests/unit/objects/test_pull_mirror.py
@@ -0,0 +1,67 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/pull_mirror.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import ProjectPullMirror
+
+
+@pytest.fixture
+def resp_pull_mirror():
+    content = {
+        "update_status": "none",
+        "url": "https://gitlab.example.com/root/mirror.git",
+        "last_error": None,
+        "last_update_at": "2024-12-03T08:01:05.466Z",
+        "last_update_started_at": "2024-12-03T08:01:05.342Z",
+        "last_successful_update_at": None,
+        "enabled": True,
+        "mirror_trigger_builds": False,
+        "only_mirror_protected_branches": None,
+        "mirror_overwrites_diverged_branches": None,
+        "mirror_branch_regex": None,
+    }
+
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.PUT,
+            url="http://localhost/api/v4/projects/1/mirror/pull",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/mirror/pull",
+            status=200,
+        )
+
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/mirror/pull",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+
+        yield rsps
+
+
+def test_create_project_pull_mirror(project, resp_pull_mirror):
+    mirror = project.pull_mirror.create(
+        {"url": "https://gitlab.example.com/root/mirror.git"}
+    )
+    assert mirror.enabled
+
+
+def test_start_project_pull_mirror(project, resp_pull_mirror):
+    project.pull_mirror.start()
+
+
+def test_get_project_pull_mirror(project, resp_pull_mirror):
+    mirror = project.pull_mirror.get()
+    assert isinstance(mirror, ProjectPullMirror)
+    assert mirror.enabled
diff --git a/tests/unit/objects/test_registry_protection_rules.py b/tests/unit/objects/test_registry_protection_rules.py
new file mode 100644
index 000000000..3078278f5
--- /dev/null
+++ b/tests/unit/objects/test_registry_protection_rules.py
@@ -0,0 +1,82 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/container_repository_protection_rules.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import ProjectRegistryRepositoryProtectionRule
+
+protected_registry_content = {
+    "id": 1,
+    "project_id": 7,
+    "repository_path_pattern": "test/image",
+    "minimum_access_level_for_push": "maintainer",
+    "minimum_access_level_for_delete": "maintainer",
+}
+
+
+@pytest.fixture
+def resp_list_protected_registries():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/registry/protection/repository/rules",
+            json=[protected_registry_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_create_protected_registry():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/registry/protection/repository/rules",
+            json=protected_registry_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_update_protected_registry():
+    updated_content = protected_registry_content.copy()
+    updated_content["repository_path_pattern"] = "abc*"
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.PATCH,
+            url="http://localhost/api/v4/projects/1/registry/protection/repository/rules/1",
+            json=updated_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_list_project_protected_registries(project, resp_list_protected_registries):
+    protected_registry = project.registry_protection_repository_rules.list()[0]
+    assert isinstance(protected_registry, ProjectRegistryRepositoryProtectionRule)
+    assert protected_registry.repository_path_pattern == "test/image"
+
+
+def test_create_project_protected_registry(project, resp_create_protected_registry):
+    protected_registry = project.registry_protection_repository_rules.create(
+        {
+            "repository_path_pattern": "test/image",
+            "minimum_access_level_for_push": "maintainer",
+        }
+    )
+    assert isinstance(protected_registry, ProjectRegistryRepositoryProtectionRule)
+    assert protected_registry.repository_path_pattern == "test/image"
+
+
+def test_update_project_protected_registry(project, resp_update_protected_registry):
+    updated = project.registry_protection_repository_rules.update(
+        1, {"repository_path_pattern": "abc*"}
+    )
+    assert updated["repository_path_pattern"] == "abc*"
diff --git a/tests/unit/objects/test_registry_repositories.py b/tests/unit/objects/test_registry_repositories.py
new file mode 100644
index 000000000..5b88a0682
--- /dev/null
+++ b/tests/unit/objects/test_registry_repositories.py
@@ -0,0 +1,92 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/container_registry.html
+"""
+
+import re
+
+import pytest
+import responses
+
+from gitlab.v4.objects import ProjectRegistryRepository, RegistryRepository
+
+repositories_content = [
+    {
+        "id": 1,
+        "name": "",
+        "path": "group/project",
+        "project_id": 9,
+        "location": "gitlab.example.com:5000/group/project",
+        "created_at": "2019-01-10T13:38:57.391Z",
+        "cleanup_policy_started_at": "2020-01-10T15:40:57.391Z",
+    },
+    {
+        "id": 2,
+        "name": "releases",
+        "path": "group/project/releases",
+        "project_id": 9,
+        "location": "gitlab.example.com:5000/group/project/releases",
+        "created_at": "2019-01-10T13:39:08.229Z",
+        "cleanup_policy_started_at": "2020-08-17T03:12:35.489Z",
+    },
+]
+
+
+@pytest.fixture
+def resp_list_registry_repositories():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=re.compile(
+                r"http://localhost/api/v4/(groups|projects)/1/registry/repositories"
+            ),
+            json=repositories_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_registry_repository():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/registry/repositories/1",
+            json=repositories_content[0],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_delete_registry_repository():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/projects/1/registry/repositories/1",
+            status=204,
+        )
+        yield rsps
+
+
+def test_list_group_registry_repositories(group, resp_list_registry_repositories):
+    repositories = group.registry_repositories.list()
+    assert isinstance(repositories[0], ProjectRegistryRepository)
+    assert repositories[0].id == 1
+
+
+def test_list_project_registry_repositories(project, resp_list_registry_repositories):
+    repositories = project.repositories.list()
+    assert isinstance(repositories[0], ProjectRegistryRepository)
+    assert repositories[0].id == 1
+
+
+def test_delete_project_registry_repository(project, resp_delete_registry_repository):
+    project.repositories.delete(1)
+
+
+def test_get_registry_repository(gl, resp_get_registry_repository):
+    repository = gl.registry_repositories.get(1)
+    assert isinstance(repository, RegistryRepository)
+    assert repository.id == 1
diff --git a/tests/unit/objects/test_releases.py b/tests/unit/objects/test_releases.py
new file mode 100644
index 000000000..ee4a9d6ce
--- /dev/null
+++ b/tests/unit/objects/test_releases.py
@@ -0,0 +1,162 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/releases/index.html
+https://docs.gitlab.com/ee/api/releases/links.html
+"""
+
+import re
+
+import pytest
+import responses
+
+from gitlab.v4.objects import ProjectReleaseLink
+
+tag_name = "v1.0.0"
+release_name = "demo-release"
+release_description = "my-rel-desc"
+released_at = "2019-03-15T08:00:00Z"
+link_name = "hello-world"
+link_url = "https://gitlab.example.com/group/hello/-/jobs/688/artifacts/raw/bin/hello-darwin-amd64"
+direct_url = f"https://gitlab.example.com/group/hello/-/releases/{tag_name}/downloads/hello-world"
+new_link_type = "package"
+link_content = {
+    "id": 2,
+    "name": link_name,
+    "url": link_url,
+    "direct_asset_url": direct_url,
+    "external": False,
+    "link_type": "other",
+}
+
+release_content = {
+    "id": 3,
+    "tag_name": tag_name,
+    "name": release_name,
+    "description": release_description,
+    "milestones": [],
+    "released_at": released_at,
+}
+
+release_url = re.compile(rf"http://localhost/api/v4/projects/1/releases/{tag_name}")
+links_url = re.compile(
+    rf"http://localhost/api/v4/projects/1/releases/{tag_name}/assets/links"
+)
+link_id_url = re.compile(
+    rf"http://localhost/api/v4/projects/1/releases/{tag_name}/assets/links/1"
+)
+
+
+@pytest.fixture
+def resp_list_links():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=links_url,
+            json=[link_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_link():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=link_id_url,
+            json=link_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_create_link():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url=links_url,
+            json=link_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_update_link():
+    updated_content = dict(link_content)
+    updated_content["link_type"] = new_link_type
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.PUT,
+            url=link_id_url,
+            json=updated_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_delete_link():
+    with responses.RequestsMock() as rsps:
+        rsps.add(method=responses.DELETE, url=link_id_url, status=204)
+        yield rsps
+
+
+@pytest.fixture
+def resp_update_release():
+    updated_content = dict(release_content)
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.PUT,
+            url=release_url,
+            json=updated_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_list_release_links(release, resp_list_links):
+    links = release.links.list()
+    assert isinstance(links, list)
+    assert isinstance(links[0], ProjectReleaseLink)
+    assert links[0].url == link_url
+
+
+def test_get_release_link(release, resp_get_link):
+    link = release.links.get(1)
+    assert isinstance(link, ProjectReleaseLink)
+    assert link.url == link_url
+
+
+def test_create_release_link(release, resp_create_link):
+    link = release.links.create({"url": link_url, "name": link_name})
+    assert isinstance(link, ProjectReleaseLink)
+    assert link.url == link_url
+
+
+def test_update_release_link(release, resp_update_link):
+    link = release.links.get(1, lazy=True)
+    link.link_type = new_link_type
+    link.save()
+    assert link.link_type == new_link_type
+
+
+def test_delete_release_link(release, resp_delete_link):
+    link = release.links.get(1, lazy=True)
+    link.delete()
+
+
+def test_update_release(release, resp_update_release):
+    release.name = release_name
+    release.description = release_description
+    release.save()
+    assert release.name == release_name
+    assert release.description == release_description
diff --git a/tests/unit/objects/test_remote_mirrors.py b/tests/unit/objects/test_remote_mirrors.py
new file mode 100644
index 000000000..f493032e8
--- /dev/null
+++ b/tests/unit/objects/test_remote_mirrors.py
@@ -0,0 +1,83 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/remote_mirrors.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import ProjectRemoteMirror
+
+
+@pytest.fixture
+def resp_remote_mirrors():
+    content = {
+        "enabled": True,
+        "id": 1,
+        "last_error": None,
+        "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": "none",
+        "url": "https://*****:*****@gitlab.com/gitlab-org/security/gitlab.git",
+    }
+
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/remote_mirrors",
+            json=[content],
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/remote_mirrors",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+
+        updated_content = dict(content)
+        updated_content["update_status"] = "finished"
+
+        rsps.add(
+            method=responses.PUT,
+            url="http://localhost/api/v4/projects/1/remote_mirrors/1",
+            json=updated_content,
+            content_type="application/json",
+            status=200,
+        )
+
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/projects/1/remote_mirrors/1",
+            status=204,
+        )
+        yield rsps
+
+
+def test_list_project_remote_mirrors(project, resp_remote_mirrors):
+    mirrors = project.remote_mirrors.list()
+    assert isinstance(mirrors, list)
+    assert isinstance(mirrors[0], ProjectRemoteMirror)
+    assert mirrors[0].enabled
+
+
+def test_create_project_remote_mirror(project, resp_remote_mirrors):
+    mirror = project.remote_mirrors.create({"url": "https://example.com"})
+    assert isinstance(mirror, ProjectRemoteMirror)
+    assert mirror.update_status == "none"
+
+
+def test_update_project_remote_mirror(project, resp_remote_mirrors):
+    mirror = project.remote_mirrors.create({"url": "https://example.com"})
+    mirror.only_protected_branches = True
+    mirror.save()
+    assert mirror.update_status == "finished"
+    assert mirror.only_protected_branches
+
+
+def test_delete_project_remote_mirror(project, resp_remote_mirrors):
+    mirror = project.remote_mirrors.create({"url": "https://example.com"})
+    mirror.delete()
diff --git a/tests/unit/objects/test_repositories.py b/tests/unit/objects/test_repositories.py
new file mode 100644
index 000000000..f891d4d09
--- /dev/null
+++ b/tests/unit/objects/test_repositories.py
@@ -0,0 +1,96 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/repositories.html
+https://docs.gitlab.com/ee/api/repository_files.html
+"""
+
+from urllib.parse import quote
+
+import pytest
+import responses
+from requests.structures import CaseInsensitiveDict
+
+from gitlab.v4.objects import ProjectFile
+
+file_path = "app/models/key.rb"
+ref = "main"
+
+
+@pytest.fixture
+def resp_head_repository_file():
+    header_response = {
+        "Cache-Control": "no-cache",
+        "Content-Length": "0",
+        "Content-Type": "application/json",
+        "Date": "Thu, 12 Sep 2024 14:27:49 GMT",
+        "Referrer-Policy": "strict-origin-when-cross-origin",
+        "Server": "nginx",
+        "Strict-Transport-Security": "max-age=63072000",
+        "Vary": "Origin",
+        "X-Content-Type-Options": "nosniff",
+        "X-Frame-Options": "SAMEORIGIN",
+        "X-Gitlab-Blob-Id": "79f7bbd25901e8334750839545a9bd021f0e4c83",
+        "X-Gitlab-Commit-Id": "d5a3ff139356ce33e37e73add446f16869741b50",
+        "X-Gitlab-Content-Sha256": "4c294617b60715c1d218e61164a3abd4808a4284cbc30e6728a01ad9aada4481",
+        "X-Gitlab-Encoding": "base64",
+        "X-Gitlab-Execute-Filemode": "false",
+        "X-Gitlab-File-Name": "key.rb",
+        "X-Gitlab-File-Path": file_path,
+        "X-Gitlab-Last-Commit-Id": "570e7b2abdd848b95f2f578043fc23bd6f6fd24d",
+        "X-Gitlab-Meta": '{"correlation_id":"01J7KFRPXBX65Y04HEH7MFX4GD","version":"1"}',
+        "X-Gitlab-Ref": ref,
+        "X-Gitlab-Size": "1476",
+        "X-Request-Id": "01J7KFRPXBX65Y04HEH7MFX4GD",
+        "X-Runtime": "0.083199",
+        "Connection": "keep-alive",
+    }
+    encoded_path = quote(file_path, safe="")
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.HEAD,
+            url=f"http://localhost/api/v4/projects/1/repository/files/{encoded_path}",
+            headers=header_response,
+            status=200,
+        )
+        yield rsps
+
+
+def test_head_repository_file(project, resp_head_repository_file):
+    headers = project.files.head(file_path, ref=ref)
+    assert isinstance(headers, CaseInsensitiveDict)
+    assert headers["X-Gitlab-File-Path"] == file_path
+
+
+@pytest.fixture
+def resp_get_repository_file():
+    file_response = {
+        "file_name": "key.rb",
+        "file_path": file_path,
+        "size": 1476,
+        "encoding": "base64",
+        "content": "IyA9PSBTY2hlbWEgSW5mb3...",
+        "content_sha256": "4c294617b60715c1d218e61164a3abd4808a4284cbc30e6728a01ad9aada4481",
+        "ref": ref,
+        "blob_id": "79f7bbd25901e8334750839545a9bd021f0e4c83",
+        "commit_id": "d5a3ff139356ce33e37e73add446f16869741b50",
+        "last_commit_id": "570e7b2abdd848b95f2f578043fc23bd6f6fd24d",
+    }
+
+    encoded_path = quote(file_path, safe="")
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=f"http://localhost/api/v4/projects/1/repository/files/{encoded_path}",
+            json=file_response,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_get_repository_file(project, resp_get_repository_file):
+    file = project.files.get(file_path, ref=ref)
+    assert isinstance(file, ProjectFile)
+    assert file.file_path == file_path
diff --git a/tests/unit/objects/test_resource_groups.py b/tests/unit/objects/test_resource_groups.py
new file mode 100644
index 000000000..170e48761
--- /dev/null
+++ b/tests/unit/objects/test_resource_groups.py
@@ -0,0 +1,80 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/resource_groups.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import ProjectResourceGroup, ProjectResourceGroupUpcomingJob
+
+from .test_jobs import failed_job_content
+
+resource_group_content = {
+    "id": 3,
+    "key": "production",
+    "process_mode": "unordered",
+    "created_at": "2021-09-01T08:04:59.650Z",
+    "updated_at": "2021-09-01T08:04:59.650Z",
+}
+
+
+@pytest.fixture
+def resp_list_resource_groups():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/resource_groups",
+            json=[resource_group_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_resource_group():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/resource_groups/production",
+            json=resource_group_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_list_upcoming_jobs():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/resource_groups/production/upcoming_jobs",
+            json=[failed_job_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_list_project_resource_groups(project, resp_list_resource_groups):
+    resource_groups = project.resource_groups.list()
+    assert isinstance(resource_groups, list)
+    assert isinstance(resource_groups[0], ProjectResourceGroup)
+    assert resource_groups[0].process_mode == "unordered"
+
+
+def test_get_project_resource_group(project, resp_get_resource_group):
+    resource_group = project.resource_groups.get("production")
+    assert isinstance(resource_group, ProjectResourceGroup)
+    assert resource_group.process_mode == "unordered"
+
+
+def test_list_resource_group_upcoming_jobs(project, resp_list_upcoming_jobs):
+    resource_group = project.resource_groups.get("production", lazy=True)
+    upcoming_jobs = resource_group.upcoming_jobs.list()
+
+    assert isinstance(upcoming_jobs, list)
+    assert isinstance(upcoming_jobs[0], ProjectResourceGroupUpcomingJob)
+    assert upcoming_jobs[0].ref == "main"
diff --git a/tests/unit/objects/test_resource_iteration_events.py b/tests/unit/objects/test_resource_iteration_events.py
new file mode 100644
index 000000000..6b3b463c1
--- /dev/null
+++ b/tests/unit/objects/test_resource_iteration_events.py
@@ -0,0 +1,55 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/resource_iteration_events.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import ProjectIssueResourceIterationEvent
+
+issue_event_content = {"id": 1, "resource_type": "Issue"}
+
+
+@pytest.fixture()
+def resp_list_project_issue_iteration_events():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/issues/1/resource_iteration_events",
+            json=[issue_event_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture()
+def resp_get_project_issue_iteration_event():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/issues/1/resource_iteration_events/1",
+            json=issue_event_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_list_project_issue_iteration_events(
+    project_issue, resp_list_project_issue_iteration_events
+):
+    iteration_events = project_issue.resource_iteration_events.list()
+    assert isinstance(iteration_events, list)
+
+    iteration_event = iteration_events[0]
+    assert isinstance(iteration_event, ProjectIssueResourceIterationEvent)
+    assert iteration_event.resource_type == "Issue"
+
+
+def test_get_project_issue_iteration_event(
+    project_issue, resp_get_project_issue_iteration_event
+):
+    iteration_event = project_issue.resource_iteration_events.get(1)
+    assert isinstance(iteration_event, ProjectIssueResourceIterationEvent)
+    assert iteration_event.resource_type == "Issue"
diff --git a/tests/unit/objects/test_resource_label_events.py b/tests/unit/objects/test_resource_label_events.py
new file mode 100644
index 000000000..deea8a0bb
--- /dev/null
+++ b/tests/unit/objects/test_resource_label_events.py
@@ -0,0 +1,105 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/resource_label_events.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import (
+    GroupEpicResourceLabelEvent,
+    ProjectIssueResourceLabelEvent,
+    ProjectMergeRequestResourceLabelEvent,
+)
+
+
+@pytest.fixture()
+def resp_group_epic_request_label_events():
+    epic_content = {"id": 1}
+    events_content = {"id": 1, "resource_type": "Epic"}
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/groups/1/epics",
+            json=[epic_content],
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/groups/1/epics/1/resource_label_events",
+            json=[events_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture()
+def resp_merge_request_label_events():
+    mr_content = {"iid": 1}
+    events_content = {"id": 1, "resource_type": "MergeRequest"}
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/merge_requests",
+            json=[mr_content],
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/merge_requests/1/resource_label_events",
+            json=[events_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture()
+def resp_project_issue_label_events():
+    issue_content = {"iid": 1}
+    events_content = {"id": 1, "resource_type": "Issue"}
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/issues",
+            json=[issue_content],
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/issues/1/resource_label_events",
+            json=[events_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_project_issue_label_events(project, resp_project_issue_label_events):
+    issue = project.issues.list()[0]
+    label_events = issue.resourcelabelevents.list()
+    assert isinstance(label_events, list)
+    label_event = label_events[0]
+    assert isinstance(label_event, ProjectIssueResourceLabelEvent)
+    assert label_event.resource_type == "Issue"
+
+
+def test_merge_request_label_events(project, resp_merge_request_label_events):
+    mr = project.mergerequests.list()[0]
+    label_events = mr.resourcelabelevents.list()
+    assert isinstance(label_events, list)
+    label_event = label_events[0]
+    assert isinstance(label_event, ProjectMergeRequestResourceLabelEvent)
+    assert label_event.resource_type == "MergeRequest"
+
+
+def test_group_epic_request_label_events(group, resp_group_epic_request_label_events):
+    epic = group.epics.list()[0]
+    label_events = epic.resourcelabelevents.list()
+    assert isinstance(label_events, list)
+    label_event = label_events[0]
+    assert isinstance(label_event, GroupEpicResourceLabelEvent)
+    assert label_event.resource_type == "Epic"
diff --git a/tests/unit/objects/test_resource_milestone_events.py b/tests/unit/objects/test_resource_milestone_events.py
new file mode 100644
index 000000000..99faeaa65
--- /dev/null
+++ b/tests/unit/objects/test_resource_milestone_events.py
@@ -0,0 +1,73 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/resource_milestone_events.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import (
+    ProjectIssueResourceMilestoneEvent,
+    ProjectMergeRequestResourceMilestoneEvent,
+)
+
+
+@pytest.fixture()
+def resp_merge_request_milestone_events():
+    mr_content = {"iid": 1}
+    events_content = {"id": 1, "resource_type": "MergeRequest"}
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/merge_requests",
+            json=[mr_content],
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/merge_requests/1/resource_milestone_events",
+            json=[events_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture()
+def resp_project_issue_milestone_events():
+    issue_content = {"iid": 1}
+    events_content = {"id": 1, "resource_type": "Issue"}
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/issues",
+            json=[issue_content],
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/issues/1/resource_milestone_events",
+            json=[events_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_project_issue_milestone_events(project, resp_project_issue_milestone_events):
+    issue = project.issues.list()[0]
+    milestone_events = issue.resourcemilestoneevents.list()
+    assert isinstance(milestone_events, list)
+    milestone_event = milestone_events[0]
+    assert isinstance(milestone_event, ProjectIssueResourceMilestoneEvent)
+    assert milestone_event.resource_type == "Issue"
+
+
+def test_merge_request_milestone_events(project, resp_merge_request_milestone_events):
+    mr = project.mergerequests.list()[0]
+    milestone_events = mr.resourcemilestoneevents.list()
+    assert isinstance(milestone_events, list)
+    milestone_event = milestone_events[0]
+    assert isinstance(milestone_event, ProjectMergeRequestResourceMilestoneEvent)
+    assert milestone_event.resource_type == "MergeRequest"
diff --git a/tests/unit/objects/test_resource_state_events.py b/tests/unit/objects/test_resource_state_events.py
new file mode 100644
index 000000000..bf1819331
--- /dev/null
+++ b/tests/unit/objects/test_resource_state_events.py
@@ -0,0 +1,104 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/resource_state_events.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import (
+    ProjectIssueResourceStateEvent,
+    ProjectMergeRequestResourceStateEvent,
+)
+
+issue_event_content = {"id": 1, "resource_type": "Issue"}
+mr_event_content = {"id": 1, "resource_type": "MergeRequest"}
+
+
+@pytest.fixture()
+def resp_list_project_issue_state_events():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/issues/1/resource_state_events",
+            json=[issue_event_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture()
+def resp_get_project_issue_state_event():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/issues/1/resource_state_events/1",
+            json=issue_event_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture()
+def resp_list_merge_request_state_events():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/merge_requests/1/resource_state_events",
+            json=[mr_event_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture()
+def resp_get_merge_request_state_event():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/merge_requests/1/resource_state_events/1",
+            json=mr_event_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_list_project_issue_state_events(
+    project_issue, resp_list_project_issue_state_events
+):
+    state_events = project_issue.resourcestateevents.list()
+    assert isinstance(state_events, list)
+
+    state_event = state_events[0]
+    assert isinstance(state_event, ProjectIssueResourceStateEvent)
+    assert state_event.resource_type == "Issue"
+
+
+def test_get_project_issue_state_event(
+    project_issue, resp_get_project_issue_state_event
+):
+    state_event = project_issue.resourcestateevents.get(1)
+    assert isinstance(state_event, ProjectIssueResourceStateEvent)
+    assert state_event.resource_type == "Issue"
+
+
+def test_list_merge_request_state_events(
+    project_merge_request, resp_list_merge_request_state_events
+):
+    state_events = project_merge_request.resourcestateevents.list()
+    assert isinstance(state_events, list)
+
+    state_event = state_events[0]
+    assert isinstance(state_event, ProjectMergeRequestResourceStateEvent)
+    assert state_event.resource_type == "MergeRequest"
+
+
+def test_get_merge_request_state_event(
+    project_merge_request, resp_get_merge_request_state_event
+):
+    state_event = project_merge_request.resourcestateevents.get(1)
+    assert isinstance(state_event, ProjectMergeRequestResourceStateEvent)
+    assert state_event.resource_type == "MergeRequest"
diff --git a/tests/unit/objects/test_runners.py b/tests/unit/objects/test_runners.py
new file mode 100644
index 000000000..cd77f953f
--- /dev/null
+++ b/tests/unit/objects/test_runners.py
@@ -0,0 +1,286 @@
+import re
+
+import pytest
+import responses
+
+import gitlab
+from gitlab.v4.objects.runners import Runner, RunnerAll
+
+runner_detail = {
+    "active": True,
+    "architecture": "amd64",
+    "description": "test-1-20150125",
+    "id": 6,
+    "ip_address": "127.0.0.1",
+    "is_shared": False,
+    "contacted_at": "2016-01-25T16:39:48.066Z",
+    "name": "test-runner",
+    "online": True,
+    "status": "online",
+    "platform": "linux",
+    "projects": [
+        {
+            "id": 1,
+            "name": "GitLab Community Edition",
+            "name_with_namespace": "GitLab.org / GitLab Community Edition",
+            "path": "gitlab-foss",
+            "path_with_namespace": "gitlab-org/gitlab-foss",
+        }
+    ],
+    "revision": "5nj35",
+    "tag_list": ["ruby", "mysql"],
+    "version": "v13.0.0",
+    "access_level": "ref_protected",
+    "maximum_timeout": 3600,
+}
+
+runner_shortinfo = {
+    "active": True,
+    "description": "test-1-20150125",
+    "id": 6,
+    "is_shared": False,
+    "ip_address": "127.0.0.1",
+    "name": "test-name",
+    "online": True,
+    "status": "online",
+}
+
+runner_jobs = [
+    {
+        "id": 6,
+        "ip_address": "127.0.0.1",
+        "status": "running",
+        "stage": "test",
+        "name": "test",
+        "ref": "main",
+        "tag": False,
+        "coverage": "99%",
+        "created_at": "2017-11-16T08:50:29.000Z",
+        "started_at": "2017-11-16T08:51:29.000Z",
+        "finished_at": "2017-11-16T08:53:29.000Z",
+        "duration": 120,
+        "user": {
+            "id": 1,
+            "name": "John Doe2",
+            "username": "user2",
+            "state": "active",
+            "avatar_url": "http://www.gravatar.com/avatar/c922747a93b40d1ea88262bf1aebee62?s=80&d=identicon",
+            "web_url": "http://localhost/user2",
+            "created_at": "2017-11-16T18:38:46.000Z",
+            "bio": None,
+            "location": None,
+            "public_email": "",
+            "skype": "",
+            "linkedin": "",
+            "twitter": "",
+            "website_url": "",
+            "organization": None,
+        },
+    }
+]
+
+
+@pytest.fixture
+def resp_get_runners_jobs():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/runners/6/jobs",
+            json=runner_jobs,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_runners_list():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=re.compile(r".*?(/runners(/all)?|/(groups|projects)/1/runners)"),
+            json=[runner_shortinfo],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_runner_detail():
+    with responses.RequestsMock() as rsps:
+        pattern = re.compile(r".*?/runners/6")
+        rsps.add(
+            method=responses.GET,
+            url=pattern,
+            json=runner_detail,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.PUT,
+            url=pattern,
+            json=runner_detail,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_runner_register():
+    with responses.RequestsMock() as rsps:
+        pattern = re.compile(r".*?/runners")
+        rsps.add(
+            method=responses.POST,
+            url=pattern,
+            json={"id": "6", "token": "6337ff461c94fd3fa32ba3b1ff4125"},
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_runner_enable():
+    with responses.RequestsMock() as rsps:
+        pattern = re.compile(r".*?projects/1/runners")
+        rsps.add(
+            method=responses.POST,
+            url=pattern,
+            json=runner_shortinfo,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_runner_delete():
+    with responses.RequestsMock() as rsps:
+        pattern = re.compile(r".*?/runners/6")
+        rsps.add(
+            method=responses.GET,
+            url=pattern,
+            json=runner_detail,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(method=responses.DELETE, url=pattern, status=204)
+        yield rsps
+
+
+@pytest.fixture
+def resp_runner_delete_by_token():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/runners",
+            status=204,
+            match=[responses.matchers.query_param_matcher({"token": "auth-token"})],
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_runner_disable():
+    with responses.RequestsMock() as rsps:
+        pattern = re.compile(r".*?/projects/1/runners/6")
+        rsps.add(method=responses.DELETE, url=pattern, status=204)
+        yield rsps
+
+
+@pytest.fixture
+def resp_runner_verify():
+    with responses.RequestsMock() as rsps:
+        pattern = re.compile(r".*?/runners/verify")
+        rsps.add(method=responses.POST, url=pattern, status=200)
+        yield rsps
+
+
+def test_owned_runners_list(gl: gitlab.Gitlab, resp_get_runners_list):
+    runners = gl.runners.list()
+    assert runners[0].active is True
+    assert runners[0].id == 6
+    assert runners[0].name == "test-name"
+    assert len(runners) == 1
+
+
+def test_project_runners_list(gl: gitlab.Gitlab, resp_get_runners_list):
+    runners = gl.projects.get(1, lazy=True).runners.list()
+    assert runners[0].active is True
+    assert runners[0].id == 6
+    assert runners[0].name == "test-name"
+    assert len(runners) == 1
+
+
+def test_group_runners_list(gl: gitlab.Gitlab, resp_get_runners_list):
+    runners = gl.groups.get(1, lazy=True).runners.list()
+    assert runners[0].active is True
+    assert runners[0].id == 6
+    assert runners[0].name == "test-name"
+    assert len(runners) == 1
+
+
+def test_runners_all(gl: gitlab.Gitlab, resp_get_runners_list):
+    runners = gl.runners.all()
+    assert isinstance(runners[0], Runner)
+    assert runners[0].active is True
+    assert runners[0].id == 6
+    assert runners[0].name == "test-name"
+    assert len(runners) == 1
+
+
+def test_runners_all_list(gl: gitlab.Gitlab, resp_get_runners_list):
+    runners = gl.runners_all.list()
+    assert isinstance(runners[0], RunnerAll)
+    assert runners[0].active is True
+    assert runners[0].id == 6
+    assert runners[0].name == "test-name"
+    assert len(runners) == 1
+
+
+def test_create_runner(gl: gitlab.Gitlab, resp_runner_register):
+    runner = gl.runners.create({"token": "token"})
+    assert runner.id == "6"
+    assert runner.token == "6337ff461c94fd3fa32ba3b1ff4125"
+
+
+def test_get_update_runner(gl: gitlab.Gitlab, resp_runner_detail):
+    runner = gl.runners.get(6)
+    assert runner.active is True
+    runner.tag_list.append("new")
+    runner.save()
+
+
+def test_delete_runner_by_id(gl: gitlab.Gitlab, resp_runner_delete):
+    runner = gl.runners.get(6)
+    runner.delete()
+    gl.runners.delete(6)
+
+
+def test_delete_runner_by_token(gl: gitlab.Gitlab, resp_runner_delete_by_token):
+    gl.runners.delete(token="auth-token")
+
+
+def test_disable_project_runner(gl: gitlab.Gitlab, resp_runner_disable):
+    gl.projects.get(1, lazy=True).runners.delete(6)
+
+
+def test_enable_project_runner(gl: gitlab.Gitlab, resp_runner_enable):
+    runner = gl.projects.get(1, lazy=True).runners.create({"runner_id": 6})
+    assert runner.active is True
+    assert runner.id == 6
+    assert runner.name == "test-name"
+
+
+def test_verify_runner(gl: gitlab.Gitlab, resp_runner_verify):
+    gl.runners.verify("token")
+
+
+def test_runner_jobs(gl: gitlab.Gitlab, resp_get_runners_jobs):
+    jobs = gl.runners.get(6, lazy=True).jobs.list()
+    assert jobs[0].duration == 120
+    assert jobs[0].name == "test"
+    assert jobs[0].user.get("name") == "John Doe2"
+    assert len(jobs) == 1
diff --git a/tests/unit/objects/test_secure_files.py b/tests/unit/objects/test_secure_files.py
new file mode 100644
index 000000000..b77e6c285
--- /dev/null
+++ b/tests/unit/objects/test_secure_files.py
@@ -0,0 +1,99 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/secure_files.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import ProjectSecureFile
+
+secure_file_content = {
+    "id": 1,
+    "name": "myfile.jks",
+    "checksum": "16630b189ab34b2e3504f4758e1054d2e478deda510b2b08cc0ef38d12e80aac",
+    "checksum_algorithm": "sha256",
+    "created_at": "2022-02-22T22:22:22.222Z",
+    "expires_at": None,
+    "metadata": None,
+}
+
+
+@pytest.fixture
+def resp_list_secure_files():
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/secure_files",
+            json=[secure_file_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_create_secure_file():
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/secure_files",
+            json=secure_file_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_download_secure_file(binary_content):
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/secure_files/1",
+            json=secure_file_content,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/secure_files/1/download",
+            body=binary_content,
+            content_type="application/octet-stream",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_remove_secure_file():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/projects/1/secure_files/1",
+            status=204,
+        )
+        yield rsps
+
+
+def test_list_secure_files(project, resp_list_secure_files):
+    secure_files = project.secure_files.list()
+    assert len(secure_files) == 1
+    assert secure_files[0].id == 1
+    assert secure_files[0].name == "myfile.jks"
+
+
+def test_create_secure_file(project, resp_create_secure_file):
+    secure_files = project.secure_files.create({"name": "test", "file": "myfile.jks"})
+    assert secure_files.id == 1
+    assert secure_files.name == "myfile.jks"
+
+
+def test_download_secure_file(project, binary_content, resp_download_secure_file):
+    secure_file = project.secure_files.get(1)
+    secure_content = secure_file.download()
+    assert isinstance(secure_file, ProjectSecureFile)
+    assert secure_content == binary_content
+
+
+def test_remove_secure_file(project, resp_remove_secure_file):
+    project.secure_files.delete(1)
diff --git a/tests/unit/objects/test_services.py b/tests/unit/objects/test_services.py
new file mode 100644
index 000000000..8b7a0a56b
--- /dev/null
+++ b/tests/unit/objects/test_services.py
@@ -0,0 +1,99 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/integrations.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import ProjectIntegration, ProjectService
+
+
+@pytest.fixture
+def resp_integration():
+    content = {
+        "id": 100152,
+        "title": "Pipelines emails",
+        "slug": "pipelines-email",
+        "created_at": "2019-01-14T08:46:43.637+01:00",
+        "updated_at": "2019-07-01T14:10:36.156+02:00",
+        "active": True,
+        "commit_events": True,
+        "push_events": True,
+        "issues_events": True,
+        "confidential_issues_events": True,
+        "merge_requests_events": True,
+        "tag_push_events": True,
+        "note_events": True,
+        "confidential_note_events": True,
+        "pipeline_events": True,
+        "wiki_page_events": True,
+        "job_events": True,
+        "comment_on_event_enabled": True,
+        "project_id": 1,
+    }
+
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/integrations",
+            json=[content],
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/integrations",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/integrations/pipelines-email",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        updated_content = dict(content)
+        updated_content["issues_events"] = False
+        rsps.add(
+            method=responses.PUT,
+            url="http://localhost/api/v4/projects/1/integrations/pipelines-email",
+            json=updated_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_list_active_integrations(project, resp_integration):
+    integrations = project.integrations.list()
+    assert isinstance(integrations, list)
+    assert isinstance(integrations[0], ProjectIntegration)
+    assert integrations[0].active
+    assert integrations[0].push_events
+
+
+def test_list_available_integrations(project, resp_integration):
+    integrations = project.integrations.available()
+    assert isinstance(integrations, list)
+    assert isinstance(integrations[0], str)
+
+
+def test_get_integration(project, resp_integration):
+    integration = project.integrations.get("pipelines-email")
+    assert isinstance(integration, ProjectIntegration)
+    assert integration.push_events is True
+
+
+def test_update_integration(project, resp_integration):
+    integration = project.integrations.get("pipelines-email")
+    integration.issues_events = False
+    integration.save()
+    assert integration.issues_events is False
+
+
+def test_get_service_returns_service(project, resp_integration):
+    # todo: remove when services are removed
+    service = project.services.get("pipelines-email")
+    assert isinstance(service, ProjectService)
diff --git a/tests/unit/objects/test_snippets.py b/tests/unit/objects/test_snippets.py
new file mode 100644
index 000000000..f8abb531b
--- /dev/null
+++ b/tests/unit/objects/test_snippets.py
@@ -0,0 +1,84 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/project_snippets.html
+             https://docs.gitlab.com/ee/api/snippets.html (todo)
+"""
+
+import pytest
+import responses
+
+title = "Example Snippet Title"
+visibility = "private"
+new_title = "new-title"
+
+
+@pytest.fixture
+def resp_snippet():
+    content = {
+        "title": title,
+        "description": "More verbose snippet description",
+        "file_name": "example.txt",
+        "content": "source code with multiple lines",
+        "visibility": visibility,
+    }
+
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/snippets",
+            json=[content],
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/snippets/1",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/snippets",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+
+        updated_content = dict(content)
+        updated_content["title"] = new_title
+        updated_content["visibility"] = visibility
+
+        rsps.add(
+            method=responses.PUT,
+            url="http://localhost/api/v4/projects/1/snippets",
+            json=updated_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_list_project_snippets(project, resp_snippet):
+    snippets = project.snippets.list()
+    assert len(snippets) == 1
+    assert snippets[0].title == title
+    assert snippets[0].visibility == visibility
+
+
+def test_get_project_snippet(project, resp_snippet):
+    snippet = project.snippets.get(1)
+    assert snippet.title == title
+    assert snippet.visibility == visibility
+
+
+def test_create_update_project_snippets(project, resp_snippet):
+    snippet = project.snippets.create(
+        {"title": title, "file_name": title, "content": title, "visibility": visibility}
+    )
+    assert snippet.title == title
+    assert snippet.visibility == visibility
+
+    snippet.title = new_title
+    snippet.save()
+    assert snippet.title == new_title
+    assert snippet.visibility == visibility
diff --git a/tests/unit/objects/test_statistics.py b/tests/unit/objects/test_statistics.py
new file mode 100644
index 000000000..c7ace5731
--- /dev/null
+++ b/tests/unit/objects/test_statistics.py
@@ -0,0 +1,48 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/statistics.html
+"""
+
+import pytest
+import responses
+
+content = {
+    "forks": "10",
+    "issues": "76",
+    "merge_requests": "27",
+    "notes": "954",
+    "snippets": "50",
+    "ssh_keys": "10",
+    "milestones": "40",
+    "users": "50",
+    "groups": "10",
+    "projects": "20",
+    "active_users": "50",
+}
+
+
+@pytest.fixture
+def resp_application_statistics():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/application/statistics",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+
+        yield rsps
+
+
+def test_get_statistics(gl, resp_application_statistics):
+    statistics = gl.statistics.get()
+    assert statistics.forks == content["forks"]
+    assert statistics.merge_requests == content["merge_requests"]
+    assert statistics.notes == content["notes"]
+    assert statistics.snippets == content["snippets"]
+    assert statistics.ssh_keys == content["ssh_keys"]
+    assert statistics.milestones == content["milestones"]
+    assert statistics.users == content["users"]
+    assert statistics.groups == content["groups"]
+    assert statistics.projects == content["projects"]
+    assert statistics.active_users == content["active_users"]
diff --git a/tests/unit/objects/test_status_checks.py b/tests/unit/objects/test_status_checks.py
new file mode 100644
index 000000000..14d1e73d4
--- /dev/null
+++ b/tests/unit/objects/test_status_checks.py
@@ -0,0 +1,127 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/status_checks.html
+"""
+
+import pytest
+import responses
+
+
+@pytest.fixture
+def external_status_check():
+    return {
+        "id": 1,
+        "name": "MR blocker",
+        "project_id": 1,
+        "external_url": "https://example.com/mr-blocker",
+        "hmac": True,
+        "protected_branches": [
+            {
+                "id": 1,
+                "project_id": 1,
+                "name": "main",
+                "created_at": "2020-10-12T14:04:50.787Z",
+                "updated_at": "2020-10-12T14:04:50.787Z",
+                "code_owner_approval_required": False,
+            }
+        ],
+    }
+
+
+@pytest.fixture
+def updated_external_status_check():
+    return {
+        "id": 1,
+        "name": "Updated MR blocker",
+        "project_id": 1,
+        "external_url": "https://example.com/mr-blocker",
+        "hmac": True,
+        "protected_branches": [
+            {
+                "id": 1,
+                "project_id": 1,
+                "name": "main",
+                "created_at": "2020-10-12T14:04:50.787Z",
+                "updated_at": "2020-10-12T14:04:50.787Z",
+                "code_owner_approval_required": False,
+            }
+        ],
+    }
+
+
+@pytest.fixture
+def resp_list_external_status_checks(external_status_check):
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/external_status_checks",
+            json=[external_status_check],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_create_external_status_checks(external_status_check):
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/projects/1/external_status_checks",
+            json=external_status_check,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_update_external_status_checks(updated_external_status_check):
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.PUT,
+            url="http://localhost/api/v4/groups/1/external_status_checks",
+            json=updated_external_status_check,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_delete_external_status_checks():
+    content = []
+
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/projects/1/external_status_checks/1",
+            status=204,
+        )
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/external_status_checks",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_list_external_status_checks(gl, resp_list_external_status_checks):
+    status_checks = gl.projects.get(1, lazy=True).external_status_checks.list()
+    assert len(status_checks) == 1
+    assert status_checks[0].name == "MR blocker"
+
+
+def test_create_external_status_checks(gl, resp_create_external_status_checks):
+    access_token = gl.projects.get(1, lazy=True).external_status_checks.create(
+        {"name": "MR blocker", "external_url": "https://example.com/mr-blocker"}
+    )
+    assert access_token.name == "MR blocker"
+    assert access_token.external_url == "https://example.com/mr-blocker"
+
+
+def test_delete_external_status_checks(gl, resp_delete_external_status_checks):
+    gl.projects.get(1, lazy=True).external_status_checks.delete(1)
+    status_checks = gl.projects.get(1, lazy=True).external_status_checks.list()
+    assert len(status_checks) == 0
diff --git a/tests/unit/objects/test_submodules.py b/tests/unit/objects/test_submodules.py
new file mode 100644
index 000000000..ed6804d50
--- /dev/null
+++ b/tests/unit/objects/test_submodules.py
@@ -0,0 +1,47 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/repository_submodules.html
+"""
+
+import pytest
+import responses
+
+
+@pytest.fixture
+def resp_update_submodule():
+    content = {
+        "id": "ed899a2f4b50b4370feeea94676502b42383c746",
+        "short_id": "ed899a2f4b5",
+        "title": "Message",
+        "author_name": "Author",
+        "author_email": "author@example.com",
+        "committer_name": "Author",
+        "committer_email": "author@example.com",
+        "created_at": "2018-09-20T09:26:24.000-07:00",
+        "message": "Message",
+        "parent_ids": ["ae1d9fb46aa2b07ee9836d49862ec4e2c46fbbba"],
+        "committed_date": "2018-09-20T09:26:24.000-07:00",
+        "authored_date": "2018-09-20T09:26:24.000-07:00",
+        "status": None,
+    }
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.PUT,
+            url="http://localhost/api/v4/projects/1/repository/submodules/foo%2Fbar",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_update_submodule(project, resp_update_submodule):
+    ret = project.update_submodule(
+        submodule="foo/bar",
+        branch="main",
+        commit_sha="4c3674f66071e30b3311dac9b9ccc90502a72664",
+        commit_message="Message",
+    )
+    assert isinstance(ret, dict)
+    assert ret["message"] == "Message"
+    assert ret["id"] == "ed899a2f4b50b4370feeea94676502b42383c746"
diff --git a/tests/unit/objects/test_templates.py b/tests/unit/objects/test_templates.py
new file mode 100644
index 000000000..bb926c920
--- /dev/null
+++ b/tests/unit/objects/test_templates.py
@@ -0,0 +1,94 @@
+"""
+Gitlab API:
+https://docs.gitlab.com/ce/api/templates/dockerfiles.html
+https://docs.gitlab.com/ce/api/templates/gitignores.html
+https://docs.gitlab.com/ce/api/templates/gitlab_ci_ymls.html
+https://docs.gitlab.com/ce/api/templates/licenses.html
+https://docs.gitlab.com/ce/api/project_templates.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import (
+    Dockerfile,
+    Gitignore,
+    Gitlabciyml,
+    License,
+    ProjectDockerfileTemplate,
+    ProjectGitignoreTemplate,
+    ProjectGitlabciymlTemplate,
+    ProjectIssueTemplate,
+    ProjectLicenseTemplate,
+    ProjectMergeRequestTemplate,
+)
+
+
+@pytest.mark.parametrize(
+    "tmpl, tmpl_mgr, tmpl_path",
+    [
+        (Dockerfile, "dockerfiles", "dockerfiles"),
+        (Gitignore, "gitignores", "gitignores"),
+        (Gitlabciyml, "gitlabciymls", "gitlab_ci_ymls"),
+        (License, "licenses", "licenses"),
+    ],
+    ids=["dockerfile", "gitignore", "gitlabciyml", "license"],
+)
+def test_get_template(gl, tmpl, tmpl_mgr, tmpl_path):
+    tmpl_id = "sample"
+    tmpl_content = {"name": tmpl_id, "content": "Sample template content"}
+
+    # License templates have 'key' as the id attribute, so ensure
+    # this is included in the response content
+    if tmpl == License:
+        tmpl_id = "smpl"
+        tmpl_content.update({"key": tmpl_id})
+
+    path = f"templates/{tmpl_path}/{tmpl_id}"
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=f"http://localhost/api/v4/{path}",
+            json=tmpl_content,
+        )
+
+        template = getattr(gl, tmpl_mgr).get(tmpl_id)
+
+    assert isinstance(template, tmpl)
+    assert getattr(template, template._id_attr) == tmpl_id
+
+
+@pytest.mark.parametrize(
+    "tmpl, tmpl_mgr, tmpl_path",
+    [
+        (ProjectDockerfileTemplate, "dockerfile_templates", "dockerfiles"),
+        (ProjectGitignoreTemplate, "gitignore_templates", "gitignores"),
+        (ProjectGitlabciymlTemplate, "gitlabciyml_templates", "gitlab_ci_ymls"),
+        (ProjectLicenseTemplate, "license_templates", "licenses"),
+        (ProjectIssueTemplate, "issue_templates", "issues"),
+        (ProjectMergeRequestTemplate, "merge_request_templates", "merge_requests"),
+    ],
+    ids=["dockerfile", "gitignore", "gitlabciyml", "license", "issue", "mergerequest"],
+)
+def test_get_project_template(project, tmpl, tmpl_mgr, tmpl_path):
+    tmpl_id = "sample"
+    tmpl_content = {"name": tmpl_id, "content": "Sample template content"}
+
+    # ProjectLicenseTemplate templates have 'key' as the id attribute, so ensure
+    # this is included in the response content
+    if tmpl == ProjectLicenseTemplate:
+        tmpl_id = "smpl"
+        tmpl_content.update({"key": tmpl_id})
+
+    path = f"projects/{project.id}/templates/{tmpl_path}/{tmpl_id}"
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=f"http://localhost/api/v4/{path}",
+            json=tmpl_content,
+        )
+
+        template = getattr(project, tmpl_mgr).get(tmpl_id)
+
+    assert isinstance(template, tmpl)
+    assert getattr(template, template._id_attr) == tmpl_id
diff --git a/tests/unit/objects/test_todos.py b/tests/unit/objects/test_todos.py
new file mode 100644
index 000000000..7875f1c9a
--- /dev/null
+++ b/tests/unit/objects/test_todos.py
@@ -0,0 +1,78 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/todos.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import Todo
+
+
+@pytest.fixture
+def json_content():
+    return [
+        {
+            "id": 102,
+            "project": {
+                "id": 2,
+                "name": "Gitlab Ce",
+                "name_with_namespace": "Gitlab Org / Gitlab Ce",
+                "path": "gitlab-ce",
+                "path_with_namespace": "gitlab-org/gitlab-ce",
+            },
+            "author": {"name": "Administrator", "username": "root", "id": 1},
+            "action_name": "marked",
+            "target_type": "MergeRequest",
+            "target": {
+                "id": 34,
+                "iid": 7,
+                "project_id": 2,
+                "assignee": {"name": "Administrator", "username": "root", "id": 1},
+            },
+        }
+    ]
+
+
+@pytest.fixture
+def resp_todo(json_content):
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/todos",
+            json=json_content,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/todos/102/mark_as_done",
+            json=json_content[0],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_mark_all_as_done():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/todos/mark_as_done",
+            status=204,
+        )
+        yield rsps
+
+
+def test_todo(gl, resp_todo):
+    todo = gl.todos.list()[0]
+    assert isinstance(todo, Todo)
+    assert todo.id == 102
+    assert todo.target_type == "MergeRequest"
+    assert todo.target["assignee"]["username"] == "root"
+
+    todo.mark_as_done()
+
+
+def test_todo_mark_all_as_done(gl, resp_mark_all_as_done):
+    gl.todos.mark_all_as_done()
diff --git a/tests/unit/objects/test_topics.py b/tests/unit/objects/test_topics.py
new file mode 100644
index 000000000..b142bd722
--- /dev/null
+++ b/tests/unit/objects/test_topics.py
@@ -0,0 +1,135 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ce/api/topics.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import Topic
+
+name = "GitLab"
+topic_title = "topic title"
+new_name = "gitlab-test"
+topic_content = {
+    "id": 1,
+    "name": name,
+    "title": topic_title,
+    "description": "GitLab is an open source end-to-end software development platform.",
+    "total_projects_count": 1000,
+    "avatar_url": "http://www.gravatar.com/avatar/a0d477b3ea21970ce6ffcbb817b0b435?s=80&d=identicon",
+}
+topics_url = "http://localhost/api/v4/topics"
+topic_url = f"{topics_url}/1"
+
+
+@pytest.fixture
+def resp_list_topics():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=topics_url,
+            json=[topic_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_topic():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=topic_url,
+            json=topic_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_create_topic():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url=topics_url,
+            json=topic_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_update_topic():
+    updated_content = dict(topic_content)
+    updated_content["name"] = new_name
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.PUT,
+            url=topic_url,
+            json=updated_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_delete_topic():
+    with responses.RequestsMock() as rsps:
+        rsps.add(method=responses.DELETE, url=topic_url, status=204)
+        yield rsps
+
+
+@pytest.fixture
+def resp_merge_topics():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url=f"{topics_url}/merge",
+            json=topic_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_list_topics(gl, resp_list_topics):
+    topics = gl.topics.list()
+    assert isinstance(topics, list)
+    assert isinstance(topics[0], Topic)
+    assert topics[0].name == name
+
+
+def test_get_topic(gl, resp_get_topic):
+    topic = gl.topics.get(1)
+    assert isinstance(topic, Topic)
+    assert topic.name == name
+
+
+def test_create_topic(gl, resp_create_topic):
+    topic = gl.topics.create({"name": name, "title": topic_title})
+    assert isinstance(topic, Topic)
+    assert topic.name == name
+    assert topic.title == topic_title
+
+
+def test_update_topic(gl, resp_update_topic):
+    topic = gl.topics.get(1, lazy=True)
+    topic.name = new_name
+    topic.save()
+    assert topic.name == new_name
+
+
+def test_delete_topic(gl, resp_delete_topic):
+    topic = gl.topics.get(1, lazy=True)
+    topic.delete()
+
+
+def test_merge_topic(gl, resp_merge_topics):
+    topic = gl.topics.merge(123, 1)
+    assert topic["id"] == 1
diff --git a/tests/unit/objects/test_users.py b/tests/unit/objects/test_users.py
new file mode 100644
index 000000000..ff8c4479d
--- /dev/null
+++ b/tests/unit/objects/test_users.py
@@ -0,0 +1,351 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ce/api/users.html
+https://docs.gitlab.com/ee/api/projects.html#list-projects-starred-by-a-user
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import (
+    StarredProject,
+    User,
+    UserContributedProject,
+    UserMembership,
+    UserStatus,
+)
+
+from .test_projects import project_content
+
+
+@pytest.fixture
+def resp_get_user():
+    content = {
+        "name": "name",
+        "id": 1,
+        "password": "password",
+        "username": "username",
+        "email": "email",
+    }
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/users/1",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_user_memberships():
+    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",
+        },
+    ]
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/users/1/memberships",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_activate():
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/users/1/activate",
+            json={},
+            content_type="application/json",
+            status=201,
+        )
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/users/1/deactivate",
+            json={},
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_approve():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/users/1/approve",
+            json={"message": "Success"},
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_reject():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/users/1/reject",
+            json={"message": "Success"},
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_ban():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/users/1/ban",
+            json={},
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_unban():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/users/1/unban",
+            json={},
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_user_status():
+    content = {
+        "message": "test",
+        "message_html": "<h1>Message</h1>",
+        "emoji": "thumbsup",
+    }
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/users/1/status",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_delete_user_identity():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/users/1/identities/test_provider",
+            status=204,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_follow_unfollow():
+    user = {
+        "id": 1,
+        "username": "john_smith",
+        "name": "John Smith",
+        "state": "active",
+        "avatar_url": "http://localhost:3000/uploads/user/avatar/1/cd8.jpeg",
+        "web_url": "http://localhost:3000/john_smith",
+    }
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/users/1/follow",
+            json=user,
+            content_type="application/json",
+            status=201,
+        )
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/users/1/unfollow",
+            json=user,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_followers_following():
+    content = [
+        {
+            "id": 2,
+            "name": "Lennie Donnelly",
+            "username": "evette.kilback",
+            "state": "active",
+            "avatar_url": "https://www.gravatar.com/avatar/7955171a55ac4997ed81e5976287890a?s=80&d=identicon",
+            "web_url": "http://127.0.0.1:3000/evette.kilback",
+        },
+        {
+            "id": 4,
+            "name": "Serena Bradtke",
+            "username": "cammy",
+            "state": "active",
+            "avatar_url": "https://www.gravatar.com/avatar/a2daad869a7b60d3090b7b9bef4baf57?s=80&d=identicon",
+            "web_url": "http://127.0.0.1:3000/cammy",
+        },
+    ]
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/users/1/followers",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/users/1/following",
+            json=content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_starred_projects():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/users/1/starred_projects",
+            json=[project_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_contributed_projects():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/users/1/contributed_projects",
+            json=[project_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_runner_create():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/user/runners",
+            json={"id": "6", "token": "6337ff461c94fd3fa32ba3b1ff4125"},
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+def test_get_user(gl, resp_get_user):
+    user = gl.users.get(1)
+    assert isinstance(user, User)
+    assert user.name == "name"
+    assert user.id == 1
+
+
+def test_user_memberships(user, resp_get_user_memberships):
+    memberships = user.memberships.list()
+    assert isinstance(memberships[0], UserMembership)
+    assert memberships[0].source_type == "Project"
+
+
+def test_user_status(user, resp_get_user_status):
+    status = user.status.get()
+    assert isinstance(status, UserStatus)
+    assert status.message == "test"
+    assert status.emoji == "thumbsup"
+
+
+def test_user_activate_deactivate(user, resp_activate):
+    user.activate()
+    user.deactivate()
+
+
+def test_user_approve_(user, resp_approve):
+    user.approve()
+
+
+def test_user_approve_reject(user, resp_reject):
+    user.reject()
+
+
+def test_user_ban(user, resp_ban):
+    user.ban()
+
+
+def test_user_unban(user, resp_unban):
+    user.unban()
+
+
+def test_delete_user_identity(user, resp_delete_user_identity):
+    user.identityproviders.delete("test_provider")
+
+
+def test_user_follow_unfollow(user, resp_follow_unfollow):
+    user.follow()
+    user.unfollow()
+
+
+def test_list_followers(user, resp_followers_following):
+    followers = user.followers_users.list()
+    followings = user.following_users.list()
+    assert isinstance(followers[0], User)
+    assert followers[0].id == 2
+    assert isinstance(followings[0], User)
+    assert followings[1].id == 4
+
+
+def test_list_contributed_projects(user, resp_contributed_projects):
+    projects = user.contributed_projects.list()
+    assert isinstance(projects[0], UserContributedProject)
+    assert projects[0].id == project_content["id"]
+
+
+def test_list_starred_projects(user, resp_starred_projects):
+    projects = user.starred_projects.list()
+    assert isinstance(projects[0], StarredProject)
+    assert projects[0].id == project_content["id"]
+
+
+def test_create_user_runner(current_user, resp_runner_create):
+    runner = current_user.runners.create({"runner_type": "instance_type"})
+    assert runner.id == "6"
+    assert runner.token == "6337ff461c94fd3fa32ba3b1ff4125"
diff --git a/tests/unit/objects/test_variables.py b/tests/unit/objects/test_variables.py
new file mode 100644
index 000000000..1c741b4bf
--- /dev/null
+++ b/tests/unit/objects/test_variables.py
@@ -0,0 +1,186 @@
+"""
+GitLab API:
+https://docs.gitlab.com/ee/api/instance_level_ci_variables.html
+https://docs.gitlab.com/ee/api/project_level_variables.html
+https://docs.gitlab.com/ee/api/group_level_variables.html
+"""
+
+import re
+
+import pytest
+import responses
+
+from gitlab.v4.objects import GroupVariable, ProjectVariable, Variable
+
+key = "TEST_VARIABLE_1"
+value = "TEST_1"
+new_value = "TEST_2"
+
+variable_content = {
+    "key": key,
+    "variable_type": "env_var",
+    "value": value,
+    "protected": False,
+    "masked": True,
+}
+variables_url = re.compile(
+    r"http://localhost/api/v4/(((groups|projects)/1)|(admin/ci))/variables"
+)
+variables_key_url = re.compile(
+    rf"http://localhost/api/v4/(((groups|projects)/1)|(admin/ci))/variables/{key}"
+)
+
+
+@pytest.fixture
+def resp_list_variables():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=variables_url,
+            json=[variable_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_variable():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=variables_key_url,
+            json=variable_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_create_variable():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url=variables_url,
+            json=variable_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_update_variable():
+    updated_content = dict(variable_content)
+    updated_content["value"] = new_value
+
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.PUT,
+            url=variables_key_url,
+            json=updated_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_delete_variable():
+    with responses.RequestsMock() as rsps:
+        rsps.add(method=responses.DELETE, url=variables_key_url, status=204)
+        yield rsps
+
+
+def test_list_instance_variables(gl, resp_list_variables):
+    variables = gl.variables.list()
+    assert isinstance(variables, list)
+    assert isinstance(variables[0], Variable)
+    assert variables[0].value == value
+
+
+def test_get_instance_variable(gl, resp_get_variable):
+    variable = gl.variables.get(key)
+    assert isinstance(variable, Variable)
+    assert variable.value == value
+
+
+def test_create_instance_variable(gl, resp_create_variable):
+    variable = gl.variables.create({"key": key, "value": value})
+    assert isinstance(variable, Variable)
+    assert variable.value == value
+
+
+def test_update_instance_variable(gl, resp_update_variable):
+    variable = gl.variables.get(key, lazy=True)
+    variable.value = new_value
+    variable.save()
+    assert variable.value == new_value
+
+
+def test_delete_instance_variable(gl, resp_delete_variable):
+    variable = gl.variables.get(key, lazy=True)
+    variable.delete()
+
+
+def test_list_project_variables(project, resp_list_variables):
+    variables = project.variables.list()
+    assert isinstance(variables, list)
+    assert isinstance(variables[0], ProjectVariable)
+    assert variables[0].value == value
+
+
+def test_get_project_variable(project, resp_get_variable):
+    variable = project.variables.get(key)
+    assert isinstance(variable, ProjectVariable)
+    assert variable.value == value
+
+
+def test_create_project_variable(project, resp_create_variable):
+    variable = project.variables.create({"key": key, "value": value})
+    assert isinstance(variable, ProjectVariable)
+    assert variable.value == value
+
+
+def test_update_project_variable(project, resp_update_variable):
+    variable = project.variables.get(key, lazy=True)
+    variable.value = new_value
+    variable.save()
+    assert variable.value == new_value
+
+
+def test_delete_project_variable(project, resp_delete_variable):
+    variable = project.variables.get(key, lazy=True)
+    variable.delete()
+
+
+def test_list_group_variables(group, resp_list_variables):
+    variables = group.variables.list()
+    assert isinstance(variables, list)
+    assert isinstance(variables[0], GroupVariable)
+    assert variables[0].value == value
+
+
+def test_get_group_variable(group, resp_get_variable):
+    variable = group.variables.get(key)
+    assert isinstance(variable, GroupVariable)
+    assert variable.value == value
+
+
+def test_create_group_variable(group, resp_create_variable):
+    variable = group.variables.create({"key": key, "value": value})
+    assert isinstance(variable, GroupVariable)
+    assert variable.value == value
+
+
+def test_update_group_variable(group, resp_update_variable):
+    variable = group.variables.get(key, lazy=True)
+    variable.value = new_value
+    variable.save()
+    assert variable.value == new_value
+
+
+def test_delete_group_variable(group, resp_delete_variable):
+    variable = group.variables.get(key, lazy=True)
+    variable.delete()
diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py
new file mode 100644
index 000000000..cad27afba
--- /dev/null
+++ b/tests/unit/test_cli.py
@@ -0,0 +1,233 @@
+import argparse
+import contextlib
+import io
+import os
+import tempfile
+from unittest import mock
+
+import pytest
+
+import gitlab.base
+from gitlab import cli
+from gitlab.exceptions import GitlabError
+from gitlab.mixins import CreateMixin, UpdateMixin
+from gitlab.types import RequiredOptional
+from gitlab.v4 import cli as v4_cli
+
+
+@pytest.mark.parametrize(
+    "gitlab_resource,expected_class",
+    [
+        ("class", "Class"),
+        ("test-class", "TestClass"),
+        ("test-longer-class", "TestLongerClass"),
+        ("current-user-gpg-key", "CurrentUserGPGKey"),
+        ("user-gpg-key", "UserGPGKey"),
+        ("ldap-group", "LDAPGroup"),
+    ],
+)
+def test_gitlab_resource_to_cls(gitlab_resource, expected_class):
+    def _namespace():
+        pass
+
+    ExpectedClass = type(expected_class, (gitlab.base.RESTObject,), {})
+    _namespace.__dict__[expected_class] = ExpectedClass
+
+    assert cli.gitlab_resource_to_cls(gitlab_resource, _namespace) == ExpectedClass
+
+
+@pytest.mark.parametrize(
+    "class_name,expected_gitlab_resource",
+    [
+        ("Class", "class"),
+        ("TestClass", "test-class"),
+        ("TestUPPERCASEClass", "test-uppercase-class"),
+        ("UPPERCASETestClass", "uppercase-test-class"),
+        ("CurrentUserGPGKey", "current-user-gpg-key"),
+        ("UserGPGKey", "user-gpg-key"),
+        ("LDAPGroup", "ldap-group"),
+    ],
+)
+def test_cls_to_gitlab_resource(class_name, expected_gitlab_resource):
+    TestClass = type(class_name, (), {})
+
+    assert cli.cls_to_gitlab_resource(TestClass) == expected_gitlab_resource
+
+
+@pytest.mark.parametrize(
+    "message,error,expected",
+    [("foobar", None, "foobar\n"), ("foo", GitlabError("bar"), "foo (bar)\n")],
+)
+def test_die(message, error, expected):
+    fl = io.StringIO()
+    with contextlib.redirect_stderr(fl):
+        with pytest.raises(SystemExit) as test:
+            cli.die(message, error)
+    assert fl.getvalue() == expected
+    assert test.value.code == 1
+
+
+def test_parse_value():
+    ret = cli._parse_value("foobar")
+    assert ret == "foobar"
+
+    ret = cli._parse_value(True)
+    assert ret is True
+
+    ret = cli._parse_value(1)
+    assert ret == 1
+
+    ret = cli._parse_value(None)
+    assert ret is None
+
+    fd, temp_path = tempfile.mkstemp()
+    os.write(fd, b"content")
+    os.close(fd)
+    ret = cli._parse_value(f"@{temp_path}")
+    assert ret == "content"
+    os.unlink(temp_path)
+
+    fl = io.StringIO()
+    with contextlib.redirect_stderr(fl):
+        with pytest.raises(SystemExit) as exc:
+            cli._parse_value("@/thisfileprobablydoesntexist")
+        assert fl.getvalue().startswith(
+            "FileNotFoundError: [Errno 2] No such file or directory:"
+        )
+        assert exc.value.code == 1
+
+
+def test_base_parser():
+    parser = cli._get_base_parser()
+    args = parser.parse_args(["-v", "-g", "gl_id", "-c", "foo.cfg", "-c", "bar.cfg"])
+    assert args.verbose
+    assert args.gitlab == "gl_id"
+    assert args.config_file == ["foo.cfg", "bar.cfg"]
+    assert args.ssl_verify is None
+
+
+def test_no_ssl_verify():
+    parser = cli._get_base_parser()
+    args = parser.parse_args(["--no-ssl-verify"])
+    assert args.ssl_verify is False
+
+
+def test_v4_parse_args():
+    parser = cli._get_parser()
+    args = parser.parse_args(["project", "list"])
+    assert args.gitlab_resource == "project"
+    assert args.resource_action == "list"
+
+
+def test_v4_parser():
+    parser = cli._get_parser()
+    subparsers = next(
+        action
+        for action in parser._actions
+        if isinstance(action, argparse._SubParsersAction)
+    )
+    assert subparsers is not None
+    assert "project" in subparsers.choices
+
+    user_subparsers = next(
+        action
+        for action in subparsers.choices["project"]._actions
+        if isinstance(action, argparse._SubParsersAction)
+    )
+    assert user_subparsers is not None
+    assert "list" in user_subparsers.choices
+    assert "get" in user_subparsers.choices
+    assert "delete" in user_subparsers.choices
+    assert "update" in user_subparsers.choices
+    assert "create" in user_subparsers.choices
+    assert "archive" in user_subparsers.choices
+    assert "unarchive" in user_subparsers.choices
+
+    actions = user_subparsers.choices["create"]._option_string_actions
+    assert not actions["--description"].required
+
+    user_subparsers = next(
+        action
+        for action in subparsers.choices["group"]._actions
+        if isinstance(action, argparse._SubParsersAction)
+    )
+    actions = user_subparsers.choices["create"]._option_string_actions
+    assert actions["--name"].required
+
+
+def test_extend_parser():
+    class ExceptionArgParser(argparse.ArgumentParser):
+        def error(self, message):
+            "Raise error instead of exiting on invalid arguments, to make testing easier"
+            raise ValueError(message)
+
+    class Fake(gitlab.base.RESTObject):
+        _id_attr = None
+
+    class FakeManager(CreateMixin, UpdateMixin, gitlab.base.RESTManager):
+        _path = "/fake"
+        _obj_cls = Fake
+        _create_attrs = RequiredOptional(
+            required=("create",),
+            optional=("opt_create",),
+            exclusive=("create_a", "create_b"),
+        )
+        _update_attrs = RequiredOptional(
+            required=("update",),
+            optional=("opt_update",),
+            exclusive=("update_a", "update_b"),
+        )
+
+    parser = ExceptionArgParser()
+    with mock.patch.dict(
+        "gitlab.v4.objects.__dict__", {"FakeManager": FakeManager}, clear=True
+    ):
+        v4_cli.extend_parser(parser)
+
+    assert parser.parse_args(["fake", "create", "--create", "1"])
+    assert parser.parse_args(["fake", "create", "--create", "1", "--opt-create", "1"])
+    assert parser.parse_args(["fake", "create", "--create", "1", "--create-a", "1"])
+    assert parser.parse_args(["fake", "create", "--create", "1", "--create-b", "1"])
+
+    with pytest.raises(ValueError):
+        # missing required "create"
+        parser.parse_args(["fake", "create", "--opt_create", "1"])
+
+    with pytest.raises(ValueError):
+        # both exclusive options
+        parser.parse_args(
+            ["fake", "create", "--create", "1", "--create-a", "1", "--create-b", "1"]
+        )
+
+    assert parser.parse_args(["fake", "update", "--update", "1"])
+    assert parser.parse_args(["fake", "update", "--update", "1", "--opt-update", "1"])
+    assert parser.parse_args(["fake", "update", "--update", "1", "--update-a", "1"])
+    assert parser.parse_args(["fake", "update", "--update", "1", "--update-b", "1"])
+
+    with pytest.raises(ValueError):
+        # missing required "update"
+        parser.parse_args(["fake", "update", "--opt_update", "1"])
+
+    with pytest.raises(ValueError):
+        # both exclusive options
+        parser.parse_args(
+            ["fake", "update", "--update", "1", "--update-a", "1", "--update-b", "1"]
+        )
+
+
+def test_legacy_display_without_fields_warns(fake_object_no_id):
+    printer = v4_cli.LegacyPrinter()
+
+    with mock.patch("builtins.print") as mocked:
+        printer.display(fake_object_no_id, obj=fake_object_no_id)
+
+    assert "No default fields to show" in mocked.call_args.args[0]
+
+
+def test_legacy_display_with_long_repr_truncates(fake_object_long_repr):
+    printer = v4_cli.LegacyPrinter()
+
+    with mock.patch("builtins.print") as mocked:
+        printer.display(fake_object_long_repr, obj=fake_object_long_repr)
+
+    assert len(mocked.call_args.args[0]) < 80
diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py
new file mode 100644
index 000000000..32b9c9ef9
--- /dev/null
+++ b/tests/unit/test_config.py
@@ -0,0 +1,394 @@
+import io
+import sys
+from pathlib import Path
+from textwrap import dedent
+from unittest import mock
+
+import pytest
+
+import gitlab
+from gitlab import config, const
+
+custom_user_agent = "my-package/1.0.0"
+
+valid_config = """[global]
+default = one
+ssl_verify = true
+timeout = 2
+
+[one]
+url = http://one.url
+private_token = ABCDEF
+
+[two]
+url = https://two.url
+private_token = GHIJKL
+ssl_verify = false
+timeout = 10
+
+[three]
+url = https://three.url
+private_token = MNOPQR
+ssl_verify = /path/to/CA/bundle.crt
+per_page = 50
+
+[four]
+url = https://four.url
+oauth_token = STUV
+"""
+
+ssl_verify_str_config = """[global]
+default = one
+ssl_verify = /etc/ssl/certs/ca-certificates.crt
+
+[one]
+url = http://one.url
+private_token = ABCDEF
+"""
+
+custom_user_agent_config = f"""[global]
+default = one
+user_agent = {custom_user_agent}
+
+[one]
+url = http://one.url
+private_token = ABCDEF
+"""
+
+no_default_config = """[global]
+[there]
+url = http://there.url
+private_token = ABCDEF
+"""
+
+invalid_data_config = """[global]
+[one]
+url = http://one.url
+
+[two]
+private_token = ABCDEF
+
+[three]
+meh = hem
+
+[four]
+url = http://four.url
+private_token = ABCDEF
+per_page = 200
+
+[invalid-api-version]
+url = http://invalid-api-version.url
+private_token = ABCDEF
+api_version = 1
+"""
+
+
+@pytest.fixture(autouse=True)
+def default_files(monkeypatch):
+    """Overrides mocked default files from conftest.py as we have our own mocks here."""
+    monkeypatch.setattr(gitlab.config, "_DEFAULT_FILES", config._DEFAULT_FILES)
+
+
+def global_retry_transient_errors(value: bool) -> str:
+    return f"""[global]
+default = one
+retry_transient_errors={value}
+[one]
+url = http://one.url
+private_token = ABCDEF"""
+
+
+def global_and_gitlab_retry_transient_errors(
+    global_value: bool, gitlab_value: bool
+) -> str:
+    return f"""[global]
+    default = one
+    retry_transient_errors={global_value}
+    [one]
+    url = http://one.url
+    private_token = ABCDEF
+    retry_transient_errors={gitlab_value}"""
+
+
+def _mock_nonexistent_file(*args, **kwargs):
+    raise OSError
+
+
+def _mock_existent_file(path, *args, **kwargs):
+    return path
+
+
+def test_env_config_missing_file_raises(monkeypatch):
+    monkeypatch.setenv("PYTHON_GITLAB_CFG", "/some/path")
+    with pytest.raises(config.GitlabConfigMissingError):
+        config._get_config_files()
+
+
+def test_env_config_not_defined_does_not_raise(monkeypatch):
+    with monkeypatch.context() as m:
+        m.setattr(config, "_DEFAULT_FILES", [])
+        assert config._get_config_files() == []
+
+
+def test_default_config(monkeypatch):
+    with monkeypatch.context() as m:
+        m.setattr(Path, "resolve", _mock_nonexistent_file)
+        cp = config.GitlabConfigParser()
+
+    assert cp.gitlab_id is None
+    assert cp.http_username is None
+    assert cp.http_password is None
+    assert cp.job_token is None
+    assert cp.oauth_token is None
+    assert cp.private_token is None
+    assert cp.api_version == "4"
+    assert cp.order_by is None
+    assert cp.pagination is None
+    assert cp.per_page is None
+    assert cp.retry_transient_errors is False
+    assert cp.ssl_verify is True
+    assert cp.timeout == 60
+    assert cp.url is None
+    assert cp.user_agent == const.USER_AGENT
+
+
+@mock.patch("builtins.open")
+def test_invalid_id(m_open, monkeypatch):
+    fd = io.StringIO(no_default_config)
+    fd.close = mock.Mock(return_value=None)
+    m_open.return_value = fd
+    with monkeypatch.context() as m:
+        m.setattr(Path, "resolve", _mock_existent_file)
+        config.GitlabConfigParser("there")
+        with pytest.raises(config.GitlabIDError):
+            config.GitlabConfigParser()
+        fd = io.StringIO(valid_config)
+        fd.close = mock.Mock(return_value=None)
+        m_open.return_value = fd
+        with pytest.raises(config.GitlabDataError):
+            config.GitlabConfigParser(gitlab_id="not_there")
+
+
+@mock.patch("builtins.open")
+def test_invalid_data(m_open, monkeypatch):
+    fd = io.StringIO(invalid_data_config)
+    fd.close = mock.Mock(return_value=None, side_effect=lambda: fd.seek(0))
+    m_open.return_value = fd
+
+    with monkeypatch.context() as m:
+        m.setattr(Path, "resolve", _mock_existent_file)
+        config.GitlabConfigParser("one")
+        config.GitlabConfigParser("one")
+        with pytest.raises(config.GitlabDataError):
+            config.GitlabConfigParser(gitlab_id="two")
+        with pytest.raises(config.GitlabDataError):
+            config.GitlabConfigParser(gitlab_id="three")
+
+        with pytest.raises(config.GitlabDataError) as e:
+            config.GitlabConfigParser("four")
+        assert str(e.value) == "Unsupported per_page number: 200"
+
+        with pytest.raises(config.GitlabDataError) as e:
+            config.GitlabConfigParser("invalid-api-version")
+        assert str(e.value) == "Unsupported API version: 1"
+
+
+@mock.patch("builtins.open")
+def test_valid_data(m_open, monkeypatch):
+    fd = io.StringIO(valid_config)
+    fd.close = mock.Mock(return_value=None)
+    m_open.return_value = fd
+
+    with monkeypatch.context() as m:
+        m.setattr(Path, "resolve", _mock_existent_file)
+        cp = config.GitlabConfigParser()
+    assert "one" == cp.gitlab_id
+    assert "http://one.url" == cp.url
+    assert "ABCDEF" == cp.private_token
+    assert cp.oauth_token is None
+    assert 2 == cp.timeout
+    assert cp.ssl_verify is True
+    assert cp.per_page is None
+
+    fd = io.StringIO(valid_config)
+    fd.close = mock.Mock(return_value=None)
+    m_open.return_value = fd
+    with monkeypatch.context() as m:
+        m.setattr(Path, "resolve", _mock_existent_file)
+        cp = config.GitlabConfigParser(gitlab_id="two")
+    assert "two" == cp.gitlab_id
+    assert "https://two.url" == cp.url
+    assert "GHIJKL" == cp.private_token
+    assert cp.oauth_token is None
+    assert 10 == cp.timeout
+    assert cp.ssl_verify is False
+
+    fd = io.StringIO(valid_config)
+    fd.close = mock.Mock(return_value=None)
+    m_open.return_value = fd
+    with monkeypatch.context() as m:
+        m.setattr(Path, "resolve", _mock_existent_file)
+        cp = config.GitlabConfigParser(gitlab_id="three")
+    assert "three" == cp.gitlab_id
+    assert "https://three.url" == cp.url
+    assert "MNOPQR" == cp.private_token
+    assert cp.oauth_token is None
+    assert 2 == cp.timeout
+    assert "/path/to/CA/bundle.crt" == cp.ssl_verify
+    assert 50 == cp.per_page
+
+    fd = io.StringIO(valid_config)
+    fd.close = mock.Mock(return_value=None)
+    m_open.return_value = fd
+    with monkeypatch.context() as m:
+        m.setattr(Path, "resolve", _mock_existent_file)
+        cp = config.GitlabConfigParser(gitlab_id="four")
+    assert "four" == cp.gitlab_id
+    assert "https://four.url" == cp.url
+    assert cp.private_token is None
+    assert "STUV" == cp.oauth_token
+    assert 2 == cp.timeout
+    assert cp.ssl_verify is True
+
+
+@mock.patch("builtins.open")
+def test_ssl_verify_as_str(m_open, monkeypatch):
+    fd = io.StringIO(ssl_verify_str_config)
+    fd.close = mock.Mock(return_value=None)
+    m_open.return_value = fd
+
+    with monkeypatch.context() as m:
+        m.setattr(Path, "resolve", _mock_existent_file)
+        cp = config.GitlabConfigParser()
+    assert cp.ssl_verify == "/etc/ssl/certs/ca-certificates.crt"
+
+
+@mock.patch("builtins.open")
+@pytest.mark.skipif(sys.platform.startswith("win"), reason="Not supported on Windows")
+def test_data_from_helper(m_open, monkeypatch, tmp_path):
+    helper = tmp_path / "helper.sh"
+    helper.write_text(
+        dedent(
+            """\
+            #!/bin/sh
+            echo "secret"
+            """
+        ),
+        encoding="utf-8",
+    )
+    helper.chmod(0o755)
+
+    fd = io.StringIO(
+        dedent(
+            f"""\
+            [global]
+            default = helper
+
+            [helper]
+            url = https://helper.url
+            oauth_token = helper: {helper}
+            """
+        )
+    )
+
+    fd.close = mock.Mock(return_value=None)
+    m_open.return_value = fd
+    with monkeypatch.context() as m:
+        m.setattr(Path, "resolve", _mock_existent_file)
+        cp = config.GitlabConfigParser(gitlab_id="helper")
+    assert "helper" == cp.gitlab_id
+    assert "https://helper.url" == cp.url
+    assert cp.private_token is None
+    assert "secret" == cp.oauth_token
+
+
+@mock.patch("builtins.open")
+@pytest.mark.skipif(sys.platform.startswith("win"), reason="Not supported on Windows")
+def test_from_helper_subprocess_error_raises_error(m_open, monkeypatch):
+    # using false here to force a non-zero return code
+    fd = io.StringIO(
+        dedent(
+            """\
+            [global]
+            default = helper
+
+            [helper]
+            url = https://helper.url
+            oauth_token = helper: false
+            """
+        )
+    )
+
+    fd.close = mock.Mock(return_value=None)
+    m_open.return_value = fd
+    with monkeypatch.context() as m:
+        m.setattr(Path, "resolve", _mock_existent_file)
+        with pytest.raises(config.GitlabConfigHelperError) as e:
+            config.GitlabConfigParser(gitlab_id="helper")
+
+        assert "Failed to read oauth_token value from helper" in str(e.value)
+
+
+@mock.patch("builtins.open")
+@pytest.mark.parametrize(
+    "config_string,expected_agent",
+    [
+        (valid_config, gitlab.const.USER_AGENT),
+        (custom_user_agent_config, custom_user_agent),
+    ],
+)
+def test_config_user_agent(m_open, monkeypatch, config_string, expected_agent):
+    fd = io.StringIO(config_string)
+    fd.close = mock.Mock(return_value=None)
+    m_open.return_value = fd
+
+    with monkeypatch.context() as m:
+        m.setattr(Path, "resolve", _mock_existent_file)
+        cp = config.GitlabConfigParser()
+    assert cp.user_agent == expected_agent
+
+
+@mock.patch("builtins.open")
+@pytest.mark.parametrize(
+    "config_string,expected",
+    [
+        pytest.param(valid_config, False, id="default_value"),
+        pytest.param(
+            global_retry_transient_errors(True), True, id="global_config_true"
+        ),
+        pytest.param(
+            global_retry_transient_errors(False), False, id="global_config_false"
+        ),
+        pytest.param(
+            global_and_gitlab_retry_transient_errors(False, True),
+            True,
+            id="gitlab_overrides_global_true",
+        ),
+        pytest.param(
+            global_and_gitlab_retry_transient_errors(True, False),
+            False,
+            id="gitlab_overrides_global_false",
+        ),
+        pytest.param(
+            global_and_gitlab_retry_transient_errors(True, True),
+            True,
+            id="gitlab_equals_global_true",
+        ),
+        pytest.param(
+            global_and_gitlab_retry_transient_errors(False, False),
+            False,
+            id="gitlab_equals_global_false",
+        ),
+    ],
+)
+def test_config_retry_transient_errors_when_global_config_is_set(
+    m_open, monkeypatch, config_string, expected
+):
+    fd = io.StringIO(config_string)
+    fd.close = mock.Mock(return_value=None)
+    m_open.return_value = fd
+
+    with monkeypatch.context() as m:
+        m.setattr(Path, "resolve", _mock_existent_file)
+        cp = config.GitlabConfigParser()
+    assert cp.retry_transient_errors == expected
diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py
new file mode 100644
index 000000000..6ef093950
--- /dev/null
+++ b/tests/unit/test_exceptions.py
@@ -0,0 +1,30 @@
+import pytest
+
+from gitlab import exceptions
+
+
+@pytest.mark.parametrize(
+    "kwargs,expected",
+    [
+        ({"error_message": "foo"}, "foo"),
+        ({"error_message": "foo", "response_code": "400"}, "400: foo"),
+    ],
+)
+def test_gitlab_error(kwargs, expected):
+    error = exceptions.GitlabError(**kwargs)
+    assert str(error) == expected
+
+
+def test_error_raises_from_http_error():
+    """Methods decorated with @on_http_error should raise from GitlabHttpError."""
+
+    class TestError(Exception):
+        pass
+
+    @exceptions.on_http_error(TestError)
+    def raise_error_from_http_error():
+        raise exceptions.GitlabHttpError
+
+    with pytest.raises(TestError) as context:
+        raise_error_from_http_error()
+    assert isinstance(context.value.__cause__, exceptions.GitlabHttpError)
diff --git a/tests/unit/test_gitlab.py b/tests/unit/test_gitlab.py
new file mode 100644
index 000000000..63d12bc66
--- /dev/null
+++ b/tests/unit/test_gitlab.py
@@ -0,0 +1,424 @@
+import copy
+import logging
+import pickle
+from http.client import HTTPConnection
+
+import pytest
+import requests
+import responses
+
+import gitlab
+from gitlab.config import GitlabConfigMissingError, GitlabDataError
+from tests.unit import helpers
+
+localhost = "http://localhost"
+token = "abc123"
+
+
+@pytest.fixture
+def resp_get_user():
+    return {
+        "method": responses.GET,
+        "url": "http://localhost/api/v4/user",
+        "json": {
+            "id": 1,
+            "username": "username",
+            "web_url": "http://localhost/username",
+        },
+        "content_type": "application/json",
+        "status": 200,
+    }
+
+
+@pytest.fixture
+def resp_page_1():
+    headers = {
+        "X-Page": "1",
+        "X-Next-Page": "2",
+        "X-Per-Page": "1",
+        "X-Total-Pages": "2",
+        "X-Total": "2",
+        "Link": ("<http://localhost/api/v4/tests?per_page=1&page=2>;" ' rel="next"'),
+    }
+
+    return {
+        "method": responses.GET,
+        "url": "http://localhost/api/v4/tests",
+        "json": [{"a": "b"}],
+        "headers": headers,
+        "content_type": "application/json",
+        "status": 200,
+        "match": helpers.MATCH_EMPTY_QUERY_PARAMS,
+    }
+
+
+@pytest.fixture
+def resp_page_2():
+    headers = {
+        "X-Page": "2",
+        "X-Next-Page": "2",
+        "X-Per-Page": "1",
+        "X-Total-Pages": "2",
+        "X-Total": "2",
+    }
+    params = {"per_page": "1", "page": "2"}
+
+    return {
+        "method": responses.GET,
+        "url": "http://localhost/api/v4/tests",
+        "json": [{"c": "d"}],
+        "headers": headers,
+        "content_type": "application/json",
+        "status": 200,
+        "match": [responses.matchers.query_param_matcher(params)],
+    }
+
+
+def test_gitlab_init_with_valid_api_version():
+    gl = gitlab.Gitlab(api_version="4")
+    assert gl.api_version == "4"
+
+
+def test_gitlab_init_with_invalid_api_version():
+    with pytest.raises(ModuleNotFoundError, match="gitlab.v1.objects"):
+        gitlab.Gitlab(api_version="1")
+
+
+def test_gitlab_as_context_manager():
+    with gitlab.Gitlab() as gl:
+        assert isinstance(gl, gitlab.Gitlab)
+
+
+def test_gitlab_enable_debug(gl):
+    gl.enable_debug()
+
+    logger = logging.getLogger("requests.packages.urllib3")
+    assert logger.level == logging.DEBUG
+    assert HTTPConnection.debuglevel == 1
+
+
+@responses.activate
+@pytest.mark.parametrize(
+    "status_code,response_json,expected",
+    [
+        (200, {"version": "0.0.0-pre", "revision": "abcdef"}, ("0.0.0-pre", "abcdef")),
+        (200, None, ("unknown", "unknown")),
+        (401, None, ("unknown", "unknown")),
+    ],
+)
+def test_gitlab_get_version(gl, status_code, response_json, expected):
+    responses.add(
+        method=responses.GET,
+        url="http://localhost/api/v4/version",
+        json=response_json,
+        status=status_code,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    version = gl.version()
+    assert version == expected
+
+
+@responses.activate
+@pytest.mark.parametrize(
+    "response_json,expected",
+    [({"id": "1", "plan": "premium"}, {"id": "1", "plan": "premium"}), (None, {})],
+)
+def test_gitlab_get_license(gl, response_json, expected):
+    responses.add(
+        method=responses.GET,
+        url="http://localhost/api/v4/license",
+        json=response_json,
+        status=200,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    gitlab_license = gl.get_license()
+    assert gitlab_license == expected
+
+
+@responses.activate
+def test_gitlab_set_license(gl):
+    responses.add(
+        method=responses.POST,
+        url="http://localhost/api/v4/license",
+        json={"id": 1, "plan": "premium"},
+        status=201,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    gitlab_license = gl.set_license("yJkYXRhIjoiMHM5Q")
+    assert gitlab_license["plan"] == "premium"
+
+
+@responses.activate
+def test_gitlab_build_list(gl, resp_page_1, resp_page_2):
+    responses.add(**resp_page_1)
+    obj = gl.http_list("/tests", iterator=True)
+    assert len(obj) == 2
+    assert obj._next_url == "http://localhost/api/v4/tests?per_page=1&page=2"
+    assert obj.current_page == 1
+    assert obj.prev_page is None
+    assert obj.next_page == 2
+    assert obj.per_page == 1
+    assert obj.total_pages == 2
+    assert obj.total == 2
+
+    responses.add(**resp_page_2)
+    test_list = list(obj)
+    assert len(test_list) == 2
+    assert test_list[0]["a"] == "b"
+    assert test_list[1]["c"] == "d"
+
+
+def _strip_pagination_headers(response):
+    """
+    https://docs.gitlab.com/ee/user/gitlab_com/index.html#pagination-response-headers
+    """
+    stripped = copy.deepcopy(response)
+
+    del stripped["headers"]["X-Total-Pages"]
+    del stripped["headers"]["X-Total"]
+
+    return stripped
+
+
+@responses.activate
+def test_gitlab_build_list_missing_headers(gl, resp_page_1, resp_page_2):
+    stripped_page_1 = _strip_pagination_headers(resp_page_1)
+    stripped_page_2 = _strip_pagination_headers(resp_page_2)
+
+    responses.add(**stripped_page_1)
+    obj = gl.http_list("/tests", iterator=True)
+    assert len(obj) == 0  # Lazy generator has no knowledge of total items
+    assert obj.total_pages is None
+    assert obj.total is None
+
+    responses.add(**stripped_page_2)
+    test_list = list(obj)
+    assert len(test_list) == 2  # List has total items after making the API calls
+
+
+@responses.activate
+def test_gitlab_get_all_omitted_when_iterator(gl, resp_page_1, resp_page_2):
+    responses.add(**resp_page_1)
+    responses.add(**resp_page_2)
+    result = gl.http_list("/tests", iterator=True, get_all=True)
+    assert isinstance(result, gitlab.GitlabList)
+
+
+def test_gitlab_strip_base_url(gl_trailing):
+    assert gl_trailing.url == "http://localhost"
+
+
+def test_gitlab_strip_api_url(gl_trailing):
+    assert gl_trailing.api_url == "http://localhost/api/v4"
+
+
+def test_gitlab_build_url(gl_trailing):
+    r = gl_trailing._build_url("/projects")
+    assert r == "http://localhost/api/v4/projects"
+
+
+def test_gitlab_pickability(gl):
+    original_gl_objects = gl._objects
+    pickled = pickle.dumps(gl)
+    unpickled = pickle.loads(pickled)
+    assert isinstance(unpickled, gitlab.Gitlab)
+    assert hasattr(unpickled, "_objects")
+    assert unpickled._objects == original_gl_objects
+
+
+@responses.activate
+def test_gitlab_token_auth(gl, resp_get_user):
+    responses.add(**resp_get_user)
+    gl.auth()
+    assert gl.user.username == "username"
+    assert gl.user.id == 1
+    assert isinstance(gl.user, gitlab.v4.objects.CurrentUser)
+
+
+@responses.activate
+def test_gitlab_auth_with_mismatching_url_warns():
+    responses.add(
+        method=responses.GET,
+        url="http://first.example.com/api/v4/user",
+        json={
+            "username": "test-user",
+            "web_url": "http://second.example.com/test-user",
+        },
+        content_type="application/json",
+        status=200,
+    )
+    gl = gitlab.Gitlab("http://first.example.com")
+
+    with pytest.warns(UserWarning):
+        gl.auth()
+
+
+def test_gitlab_default_url():
+    gl = gitlab.Gitlab()
+    assert gl.url == gitlab.const.DEFAULT_URL
+
+
+@pytest.mark.parametrize(
+    "args, kwargs, expected_url, expected_private_token, expected_oauth_token",
+    [
+        ([], {}, gitlab.const.DEFAULT_URL, None, None),
+        ([None, token], {}, gitlab.const.DEFAULT_URL, token, None),
+        ([localhost], {}, localhost, None, None),
+        ([localhost, token], {}, localhost, token, None),
+        ([localhost, None, token], {}, localhost, None, token),
+        ([], {"private_token": token}, gitlab.const.DEFAULT_URL, token, None),
+        ([], {"oauth_token": token}, gitlab.const.DEFAULT_URL, None, token),
+        ([], {"url": localhost}, localhost, None, None),
+        ([], {"url": localhost, "private_token": token}, localhost, token, None),
+        ([], {"url": localhost, "oauth_token": token}, localhost, None, token),
+    ],
+    ids=[
+        "no_args",
+        "args_private_token",
+        "args_url",
+        "args_url_private_token",
+        "args_url_oauth_token",
+        "kwargs_private_token",
+        "kwargs_oauth_token",
+        "kwargs_url",
+        "kwargs_url_private_token",
+        "kwargs_url_oauth_token",
+    ],
+)
+def test_gitlab_args_kwargs(
+    args, kwargs, expected_url, expected_private_token, expected_oauth_token
+):
+    gl = gitlab.Gitlab(*args, **kwargs)
+    assert gl.url == expected_url
+    assert gl.private_token == expected_private_token
+    assert gl.oauth_token == expected_oauth_token
+
+
+def test_gitlab_from_config(default_config):
+    config_path = default_config
+    gitlab.Gitlab.from_config("one", [config_path])
+
+
+def test_gitlab_from_config_without_files_raises():
+    with pytest.raises(GitlabConfigMissingError, match="non-existing"):
+        gitlab.Gitlab.from_config("non-existing")
+
+
+def test_gitlab_from_config_with_wrong_gitlab_id_raises(default_config):
+    with pytest.raises(GitlabDataError, match="non-existing"):
+        gitlab.Gitlab.from_config("non-existing", [default_config])
+
+
+def test_gitlab_subclass_from_config(default_config):
+    class MyGitlab(gitlab.Gitlab):
+        pass
+
+    config_path = default_config
+    gl = MyGitlab.from_config("one", [config_path])
+    assert isinstance(gl, MyGitlab)
+
+
+@pytest.mark.parametrize(
+    "kwargs,expected_agent",
+    [
+        ({}, gitlab.const.USER_AGENT),
+        ({"user_agent": "my-package/1.0.0"}, "my-package/1.0.0"),
+    ],
+)
+def test_gitlab_user_agent(kwargs, expected_agent):
+    gl = gitlab.Gitlab("http://localhost", **kwargs)
+    assert gl.headers["User-Agent"] == expected_agent
+
+
+def test_gitlab_enum_const_does_not_warn(recwarn):
+    no_access = gitlab.const.AccessLevel.NO_ACCESS
+
+    assert not recwarn
+    assert no_access == 0
+
+
+def test_gitlab_plain_const_does_not_warn(recwarn):
+    no_access = gitlab.const.NO_ACCESS
+
+    assert not recwarn
+    assert no_access == 0
+
+
+@responses.activate
+@pytest.mark.parametrize(
+    "kwargs,link_header,expected_next_url,show_warning",
+    [
+        (
+            {},
+            "<http://localhost/api/v4/tests?per_page=1&page=2>;" ' rel="next"',
+            "http://localhost/api/v4/tests?per_page=1&page=2",
+            False,
+        ),
+        (
+            {},
+            "<http://orig_host/api/v4/tests?per_page=1&page=2>;" ' rel="next"',
+            "http://orig_host/api/v4/tests?per_page=1&page=2",
+            True,
+        ),
+        (
+            {"keep_base_url": True},
+            "<http://orig_host/api/v4/tests?per_page=1&page=2>;" ' rel="next"',
+            "http://localhost/api/v4/tests?per_page=1&page=2",
+            False,
+        ),
+    ],
+    ids=["url-match-does-not-warn", "url-mismatch-warns", "url-mismatch-keeps-url"],
+)
+def test_gitlab_keep_base_url(kwargs, link_header, expected_next_url, show_warning):
+    responses.add(
+        **{
+            "method": responses.GET,
+            "url": "http://localhost/api/v4/tests",
+            "json": [{"a": "b"}],
+            "headers": {
+                "X-Page": "1",
+                "X-Next-Page": "2",
+                "X-Per-Page": "1",
+                "X-Total-Pages": "2",
+                "X-Total": "2",
+                "Link": (link_header),
+            },
+            "content_type": "application/json",
+            "status": 200,
+            "match": helpers.MATCH_EMPTY_QUERY_PARAMS,
+        }
+    )
+
+    gl = gitlab.Gitlab(url="http://localhost", **kwargs)
+    if show_warning:
+        with pytest.warns(UserWarning) as warn_record:
+            obj = gl.http_list("/tests", iterator=True)
+        assert len(warn_record) == 1
+    else:
+        obj = gl.http_list("/tests", iterator=True)
+    assert obj._next_url == expected_next_url
+
+
+def test_no_custom_session(default_config):
+    """Test no custom session"""
+
+    config_path = default_config
+    custom_session = requests.Session()
+    test_gitlab = gitlab.Gitlab.from_config("one", [config_path])
+
+    assert test_gitlab.session != custom_session
+
+
+def test_custom_session(default_config):
+    """Test custom session"""
+
+    config_path = default_config
+    custom_session = requests.Session()
+    test_gitlab = gitlab.Gitlab.from_config(
+        "one", [config_path], session=custom_session
+    )
+
+    assert test_gitlab.session == custom_session
diff --git a/tests/unit/test_gitlab_auth.py b/tests/unit/test_gitlab_auth.py
new file mode 100644
index 000000000..0c6d68251
--- /dev/null
+++ b/tests/unit/test_gitlab_auth.py
@@ -0,0 +1,229 @@
+import pathlib
+
+import pytest
+import requests
+import responses
+from requests import PreparedRequest
+
+from gitlab import Gitlab
+from gitlab._backends import JobTokenAuth, OAuthTokenAuth, PrivateTokenAuth
+from gitlab.config import GitlabConfigParser
+
+
+@pytest.fixture
+def netrc(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path):
+    netrc_file = tmp_path / ".netrc"
+    netrc_file.write_text("machine localhost login test password test")
+    monkeypatch.setenv("NETRC", str(netrc_file))
+
+
+def test_invalid_auth_args():
+    with pytest.raises(ValueError):
+        Gitlab(
+            "http://localhost",
+            api_version="4",
+            private_token="private_token",
+            oauth_token="bearer",
+        )
+    with pytest.raises(ValueError):
+        Gitlab(
+            "http://localhost",
+            api_version="4",
+            oauth_token="bearer",
+            http_username="foo",
+            http_password="bar",
+        )
+    with pytest.raises(ValueError):
+        Gitlab(
+            "http://localhost",
+            api_version="4",
+            private_token="private_token",
+            http_password="bar",
+        )
+    with pytest.raises(ValueError):
+        Gitlab(
+            "http://localhost",
+            api_version="4",
+            private_token="private_token",
+            http_username="foo",
+        )
+
+
+def test_private_token_auth():
+    gl = Gitlab("http://localhost", private_token="private_token", api_version="4")
+    p = PreparedRequest()
+    p.prepare(url=gl.url, auth=gl._auth)
+    assert gl.private_token == "private_token"
+    assert gl.oauth_token is None
+    assert gl.job_token is None
+    assert isinstance(gl._auth, PrivateTokenAuth)
+    assert gl._auth.token == "private_token"
+    assert p.headers["PRIVATE-TOKEN"] == "private_token"
+    assert "JOB-TOKEN" not in p.headers
+    assert "Authorization" not in p.headers
+
+
+def test_oauth_token_auth():
+    gl = Gitlab("http://localhost", oauth_token="oauth_token", api_version="4")
+    p = PreparedRequest()
+    p.prepare(url=gl.url, auth=gl._auth)
+    assert gl.private_token is None
+    assert gl.oauth_token == "oauth_token"
+    assert gl.job_token is None
+    assert isinstance(gl._auth, OAuthTokenAuth)
+    assert gl._auth.token == "oauth_token"
+    assert p.headers["Authorization"] == "Bearer oauth_token"
+    assert "PRIVATE-TOKEN" not in p.headers
+    assert "JOB-TOKEN" not in p.headers
+
+
+def test_job_token_auth():
+    gl = Gitlab("http://localhost", job_token="CI_JOB_TOKEN", api_version="4")
+    p = PreparedRequest()
+    p.prepare(url=gl.url, auth=gl._auth)
+    assert gl.private_token is None
+    assert gl.oauth_token is None
+    assert gl.job_token == "CI_JOB_TOKEN"
+    assert isinstance(gl._auth, JobTokenAuth)
+    assert gl._auth.token == "CI_JOB_TOKEN"
+    assert p.headers["JOB-TOKEN"] == "CI_JOB_TOKEN"
+    assert "PRIVATE-TOKEN" not in p.headers
+    assert "Authorization" not in p.headers
+
+
+def test_http_auth():
+    gl = Gitlab(
+        "http://localhost", http_username="foo", http_password="bar", api_version="4"
+    )
+    p = PreparedRequest()
+    p.prepare(url=gl.url, auth=gl._auth)
+    assert gl.private_token is None
+    assert gl.oauth_token is None
+    assert gl.job_token is None
+    assert isinstance(gl._auth, requests.auth.HTTPBasicAuth)
+    assert gl._auth.username == "foo"
+    assert gl._auth.password == "bar"
+    assert p.headers["Authorization"] == "Basic Zm9vOmJhcg=="
+    assert "PRIVATE-TOKEN" not in p.headers
+    assert "JOB-TOKEN" not in p.headers
+
+
+@responses.activate
+def test_with_no_auth_uses_netrc_file(netrc):
+    responses.get(
+        url="http://localhost/api/v4/test",
+        match=[
+            responses.matchers.header_matcher({"Authorization": "Basic dGVzdDp0ZXN0"})
+        ],
+    )
+
+    gl = Gitlab("http://localhost")
+    gl.http_get("/test")
+
+
+@responses.activate
+def test_with_auth_ignores_netrc_file(netrc):
+    responses.get(
+        url="http://localhost/api/v4/test",
+        match=[responses.matchers.header_matcher({"Authorization": "Bearer test"})],
+    )
+
+    gl = Gitlab("http://localhost", oauth_token="test")
+    gl.http_get("/test")
+
+
+@pytest.mark.parametrize(
+    "options,config,expected_private_token,expected_oauth_token,expected_job_token",
+    [
+        (
+            {
+                "private_token": "options-private-token",
+                "oauth_token": "options-oauth-token",
+                "job_token": "options-job-token",
+            },
+            {
+                "private_token": "config-private-token",
+                "oauth_token": "config-oauth-token",
+                "job_token": "config-job-token",
+            },
+            "options-private-token",
+            None,
+            None,
+        ),
+        (
+            {
+                "private_token": None,
+                "oauth_token": "options-oauth-token",
+                "job_token": "options-job-token",
+            },
+            {
+                "private_token": "config-private-token",
+                "oauth_token": "config-oauth-token",
+                "job_token": "config-job-token",
+            },
+            "config-private-token",
+            None,
+            None,
+        ),
+        (
+            {
+                "private_token": None,
+                "oauth_token": None,
+                "job_token": "options-job-token",
+            },
+            {
+                "private_token": "config-private-token",
+                "oauth_token": "config-oauth-token",
+                "job_token": "config-job-token",
+            },
+            "config-private-token",
+            None,
+            None,
+        ),
+        (
+            {"private_token": None, "oauth_token": None, "job_token": None},
+            {
+                "private_token": "config-private-token",
+                "oauth_token": "config-oauth-token",
+                "job_token": "config-job-token",
+            },
+            "config-private-token",
+            None,
+            None,
+        ),
+        (
+            {"private_token": None, "oauth_token": None, "job_token": None},
+            {
+                "private_token": None,
+                "oauth_token": "config-oauth-token",
+                "job_token": "config-job-token",
+            },
+            None,
+            "config-oauth-token",
+            None,
+        ),
+        (
+            {"private_token": None, "oauth_token": None, "job_token": None},
+            {
+                "private_token": None,
+                "oauth_token": None,
+                "job_token": "config-job-token",
+            },
+            None,
+            None,
+            "config-job-token",
+        ),
+    ],
+)
+def test_merge_auth(
+    options, config, expected_private_token, expected_oauth_token, expected_job_token
+):
+    cp = GitlabConfigParser()
+    cp.private_token = config["private_token"]
+    cp.oauth_token = config["oauth_token"]
+    cp.job_token = config["job_token"]
+
+    private_token, oauth_token, job_token = Gitlab._merge_auth(options, cp)
+    assert private_token == expected_private_token
+    assert oauth_token == expected_oauth_token
+    assert job_token == expected_job_token
diff --git a/tests/unit/test_gitlab_http_methods.py b/tests/unit/test_gitlab_http_methods.py
new file mode 100644
index 000000000..f85035fc2
--- /dev/null
+++ b/tests/unit/test_gitlab_http_methods.py
@@ -0,0 +1,912 @@
+import copy
+import warnings
+
+import pytest
+import requests
+import responses
+
+from gitlab import GitlabHttpError, GitlabList, GitlabParsingError, RedirectError
+from gitlab.const import RETRYABLE_TRANSIENT_ERROR_CODES
+from tests.unit import helpers
+
+
+def test_build_url(gl):
+    r = gl._build_url("http://localhost/api/v4")
+    assert r == "http://localhost/api/v4"
+    r = gl._build_url("https://localhost/api/v4")
+    assert r == "https://localhost/api/v4"
+    r = gl._build_url("/projects")
+    assert r == "http://localhost/api/v4/projects"
+
+
+@responses.activate
+def test_http_request(gl):
+    url = "http://localhost/api/v4/projects"
+    responses.add(
+        method=responses.GET,
+        url=url,
+        json=[{"name": "project1"}],
+        status=200,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    http_r = gl.http_request("get", "/projects")
+    http_r.json()
+    assert http_r.status_code == 200
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_http_request_with_url_encoded_kwargs_does_not_duplicate_params(gl):
+    url = "http://localhost/api/v4/projects?topics%5B%5D=python"
+    responses.add(
+        method=responses.GET,
+        url=url,
+        json=[{"name": "project1"}],
+        status=200,
+        match=[responses.matchers.query_param_matcher({"topics[]": "python"})],
+    )
+
+    kwargs = {"topics[]": "python"}
+    http_r = gl.http_request("get", "/projects?topics%5B%5D=python", **kwargs)
+    http_r.json()
+    assert http_r.status_code == 200
+    assert responses.assert_call_count(url, 1)
+
+
+@responses.activate
+def test_http_request_404(gl):
+    url = "http://localhost/api/v4/not_there"
+    responses.add(
+        method=responses.GET,
+        url=url,
+        json={},
+        status=400,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    with pytest.raises(GitlabHttpError):
+        gl.http_request("get", "/not_there")
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+@pytest.mark.parametrize("status_code", RETRYABLE_TRANSIENT_ERROR_CODES)
+def test_http_request_with_only_failures(gl, status_code):
+    url = "http://localhost/api/v4/projects"
+    responses.add(
+        method=responses.GET,
+        url=url,
+        json={},
+        status=status_code,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    with pytest.raises(GitlabHttpError):
+        gl.http_request("get", "/projects")
+
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_http_request_with_retry_on_method_for_transient_failures(gl):
+    call_count = 0
+    calls_before_success = 3
+
+    url = "http://localhost/api/v4/projects"
+
+    def request_callback(request):
+        nonlocal call_count
+        call_count += 1
+        status_code = 200 if call_count >= calls_before_success else 500
+        headers = {}
+        body = "[]"
+
+        return (status_code, headers, body)
+
+    responses.add_callback(
+        method=responses.GET,
+        url=url,
+        callback=request_callback,
+        content_type="application/json",
+    )
+
+    http_r = gl.http_request("get", "/projects", retry_transient_errors=True)
+
+    assert http_r.status_code == 200
+    assert len(responses.calls) == calls_before_success
+
+
+@responses.activate
+def test_http_request_extra_headers(gl):
+    path = "/projects/123/jobs/123456"
+    url = "http://localhost/api/v4" + path
+
+    range_headers = {"Range": "bytes=0-99"}
+
+    responses.add(
+        method=responses.GET,
+        url=url,
+        body=b"a" * 100,
+        status=206,
+        content_type="application/octet-stream",
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS
+        + [responses.matchers.header_matcher(range_headers)],
+    )
+
+    http_r = gl.http_request("get", path, extra_headers=range_headers)
+
+    assert http_r.status_code == 206
+    assert len(http_r.content) == 100
+
+
+@responses.activate
+@pytest.mark.parametrize(
+    "exception",
+    [
+        requests.ConnectionError("Connection aborted."),
+        requests.exceptions.ChunkedEncodingError("Connection broken."),
+    ],
+)
+def test_http_request_with_retry_on_method_for_transient_network_failures(
+    gl, exception
+):
+    call_count = 0
+    calls_before_success = 3
+
+    url = "http://localhost/api/v4/projects"
+
+    def request_callback(request):
+        nonlocal call_count
+        call_count += 1
+        status_code = 200
+        headers = {}
+        body = "[]"
+
+        if call_count >= calls_before_success:
+            return (status_code, headers, body)
+        raise exception
+
+    responses.add_callback(
+        method=responses.GET,
+        url=url,
+        callback=request_callback,
+        content_type="application/json",
+    )
+
+    http_r = gl.http_request("get", "/projects", retry_transient_errors=True)
+
+    assert http_r.status_code == 200
+    assert len(responses.calls) == calls_before_success
+
+
+@responses.activate
+def test_http_request_with_retry_on_class_for_transient_failures(gl_retry):
+    call_count = 0
+    calls_before_success = 3
+
+    url = "http://localhost/api/v4/projects"
+
+    def request_callback(request: requests.models.PreparedRequest):
+        nonlocal call_count
+        call_count += 1
+        status_code = 200 if call_count >= calls_before_success else 500
+        headers = {}
+        body = "[]"
+
+        return (status_code, headers, body)
+
+    responses.add_callback(
+        method=responses.GET,
+        url=url,
+        callback=request_callback,
+        content_type="application/json",
+    )
+
+    http_r = gl_retry.http_request("get", "/projects", retry_transient_errors=True)
+
+    assert http_r.status_code == 200
+    assert len(responses.calls) == calls_before_success
+
+
+@responses.activate
+def test_http_request_with_retry_on_class_for_transient_network_failures(gl_retry):
+    call_count = 0
+    calls_before_success = 3
+
+    url = "http://localhost/api/v4/projects"
+
+    def request_callback(request: requests.models.PreparedRequest):
+        nonlocal call_count
+        call_count += 1
+        status_code = 200
+        headers = {}
+        body = "[]"
+
+        if call_count >= calls_before_success:
+            return (status_code, headers, body)
+        raise requests.ConnectionError("Connection aborted.")
+
+    responses.add_callback(
+        method=responses.GET,
+        url=url,
+        callback=request_callback,
+        content_type="application/json",
+    )
+
+    http_r = gl_retry.http_request("get", "/projects", retry_transient_errors=True)
+
+    assert http_r.status_code == 200
+    assert len(responses.calls) == calls_before_success
+
+
+@responses.activate
+def test_http_request_with_retry_on_class_and_method_for_transient_failures(gl_retry):
+    call_count = 0
+    calls_before_success = 3
+
+    url = "http://localhost/api/v4/projects"
+
+    def request_callback(request):
+        nonlocal call_count
+        call_count += 1
+        status_code = 200 if call_count >= calls_before_success else 500
+        headers = {}
+        body = "[]"
+
+        return (status_code, headers, body)
+
+    responses.add_callback(
+        method=responses.GET,
+        url=url,
+        callback=request_callback,
+        content_type="application/json",
+    )
+
+    with pytest.raises(GitlabHttpError):
+        gl_retry.http_request("get", "/projects", retry_transient_errors=False)
+
+    assert len(responses.calls) == 1
+
+
+@responses.activate
+def test_http_request_with_retry_on_class_and_method_for_transient_network_failures(
+    gl_retry,
+):
+    call_count = 0
+    calls_before_success = 3
+
+    url = "http://localhost/api/v4/projects"
+
+    def request_callback(request):
+        nonlocal call_count
+        call_count += 1
+        status_code = 200
+        headers = {}
+        body = "[]"
+
+        if call_count >= calls_before_success:
+            return (status_code, headers, body)
+        raise requests.ConnectionError("Connection aborted.")
+
+    responses.add_callback(
+        method=responses.GET,
+        url=url,
+        callback=request_callback,
+        content_type="application/json",
+    )
+
+    with pytest.raises(requests.ConnectionError):
+        gl_retry.http_request("get", "/projects", retry_transient_errors=False)
+
+    assert len(responses.calls) == 1
+
+
+def create_redirect_response(
+    *, response: requests.models.Response, http_method: str, api_path: str
+) -> requests.models.Response:
+    """Create a Requests response object that has a redirect in it"""
+
+    assert api_path.startswith("/")
+    http_method = http_method.upper()
+
+    # Create a history which contains our original request which is redirected
+    history = [
+        helpers.httmock_response(
+            status_code=302,
+            content="",
+            headers={"Location": f"http://example.com/api/v4{api_path}"},
+            reason="Moved Temporarily",
+            request=response.request,
+        )
+    ]
+
+    # Create a "prepped" Request object to be the final redirect. The redirect
+    # will be a "GET" method as Requests changes the method to "GET" when there
+    # is a 301/302 redirect code.
+    req = requests.Request(method="GET", url=f"http://example.com/api/v4{api_path}")
+    prepped = req.prepare()
+
+    resp_obj = helpers.httmock_response(
+        status_code=200, content="", headers={}, reason="OK", elapsed=5, request=prepped
+    )
+    resp_obj.history = history
+    return resp_obj
+
+
+def test_http_request_302_get_does_not_raise(gl):
+    """Test to show that a redirect of a GET will not cause an error"""
+
+    method = "get"
+    api_path = "/user/status"
+    url = f"http://localhost/api/v4{api_path}"
+
+    def response_callback(
+        response: requests.models.Response,
+    ) -> requests.models.Response:
+        return create_redirect_response(
+            response=response, http_method=method, api_path=api_path
+        )
+
+    with responses.RequestsMock(response_callback=response_callback) as req_mock:
+        req_mock.add(
+            method=responses.GET,
+            url=url,
+            status=302,
+            match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+        )
+        gl.http_request(verb=method, path=api_path)
+
+
+def test_http_request_302_put_raises_redirect_error(gl):
+    """Test to show that a redirect of a PUT will cause an error"""
+
+    method = "put"
+    api_path = "/user/status"
+    url = f"http://localhost/api/v4{api_path}"
+
+    def response_callback(
+        response: requests.models.Response,
+    ) -> requests.models.Response:
+        return create_redirect_response(
+            response=response, http_method=method, api_path=api_path
+        )
+
+    with responses.RequestsMock(response_callback=response_callback) as req_mock:
+        req_mock.add(
+            method=responses.PUT,
+            url=url,
+            status=302,
+            match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+        )
+        with pytest.raises(RedirectError) as exc:
+            gl.http_request(verb=method, path=api_path)
+    error_message = exc.value.error_message
+    assert "Moved Temporarily" in error_message
+    assert "http://localhost/api/v4/user/status" in error_message
+    assert "http://example.com/api/v4/user/status" in error_message
+
+
+def test_http_request_on_409_resource_lock_retries(gl_retry):
+    url = "http://localhost/api/v4/user"
+    retried = False
+
+    def response_callback(
+        response: requests.models.Response,
+    ) -> requests.models.Response:
+        """We need a callback that adds a resource lock reason only on first call"""
+        nonlocal retried
+
+        if not retried:
+            response.reason = "Resource lock"
+
+        retried = True
+        return response
+
+    with responses.RequestsMock(response_callback=response_callback) as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=url,
+            status=409,
+            match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+        )
+        rsps.add(
+            method=responses.GET,
+            url=url,
+            status=200,
+            match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+        )
+        response = gl_retry.http_request("get", "/user")
+
+    assert response.status_code == 200
+
+
+def test_http_request_on_409_resource_lock_without_retry_raises(gl):
+    url = "http://localhost/api/v4/user"
+
+    def response_callback(
+        response: requests.models.Response,
+    ) -> requests.models.Response:
+        """Without retry, this will fail on the first call"""
+        response.reason = "Resource lock"
+        return response
+
+    with responses.RequestsMock(response_callback=response_callback) as req_mock:
+        req_mock.add(
+            method=responses.GET,
+            url=url,
+            status=409,
+            match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+        )
+        with pytest.raises(GitlabHttpError) as excinfo:
+            gl.http_request("get", "/user")
+
+    assert excinfo.value.response_code == 409
+
+
+@responses.activate
+def test_get_request(gl):
+    url = "http://localhost/api/v4/projects"
+    responses.add(
+        method=responses.GET,
+        url=url,
+        json={"name": "project1"},
+        status=200,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    result = gl.http_get("/projects")
+    assert isinstance(result, dict)
+    assert result["name"] == "project1"
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_get_request_raw(gl):
+    url = "http://localhost/api/v4/projects"
+    responses.add(
+        method=responses.GET,
+        url=url,
+        content_type="application/octet-stream",
+        body="content",
+        status=200,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    result = gl.http_get("/projects")
+    assert result.content.decode("utf-8") == "content"
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_get_request_404(gl):
+    url = "http://localhost/api/v4/not_there"
+    responses.add(
+        method=responses.GET,
+        url=url,
+        json=[],
+        status=404,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    with pytest.raises(GitlabHttpError):
+        gl.http_get("/not_there")
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_get_request_invalid_data(gl):
+    url = "http://localhost/api/v4/projects"
+    responses.add(
+        method=responses.GET,
+        url=url,
+        body='["name": "project1"]',
+        content_type="application/json",
+        status=200,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    with pytest.raises(GitlabParsingError):
+        result = gl.http_get("/projects")
+        print(type(result))
+        print(result.content)
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_head_request(gl):
+    url = "http://localhost/api/v4/projects"
+    responses.add(
+        method=responses.HEAD,
+        url=url,
+        headers={"X-Total": "1"},
+        status=200,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    result = gl.http_head("/projects")
+    assert isinstance(result, requests.structures.CaseInsensitiveDict)
+    assert result["X-Total"] == "1"
+
+
+@responses.activate
+def test_list_request(gl):
+    url = "http://localhost/api/v4/projects"
+    responses.add(
+        method=responses.GET,
+        url=url,
+        json=[{"name": "project1"}],
+        headers={"X-Total": "1"},
+        status=200,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    with warnings.catch_warnings(record=True) as caught_warnings:
+        result = gl.http_list("/projects", iterator=False)
+    assert len(caught_warnings) == 0
+    assert isinstance(result, list)
+    assert len(result) == 1
+
+    result = gl.http_list("/projects", iterator=True)
+    assert isinstance(result, GitlabList)
+    assert len(list(result)) == 1
+
+    result = gl.http_list("/projects", get_all=True)
+    assert isinstance(result, list)
+    assert len(result) == 1
+    assert responses.assert_call_count(url, 3) is True
+
+
+@responses.activate
+def test_list_request_page_and_iterator(gl):
+    response_dict = copy.deepcopy(large_list_response)
+    response_dict["match"] = [responses.matchers.query_param_matcher({"page": "1"})]
+    responses.add(**response_dict)
+
+    with pytest.warns(
+        UserWarning, match="`iterator=True` and `page=1` were both specified"
+    ):
+        result = gl.http_list("/projects", iterator=True, page=1)
+    assert isinstance(result, GitlabList)
+    assert len(list(result)) == 20
+    assert len(responses.calls) == 1
+
+
+large_list_response = {
+    "method": responses.GET,
+    "url": "http://localhost/api/v4/projects",
+    "json": [
+        {"name": "project01"},
+        {"name": "project02"},
+        {"name": "project03"},
+        {"name": "project04"},
+        {"name": "project05"},
+        {"name": "project06"},
+        {"name": "project07"},
+        {"name": "project08"},
+        {"name": "project09"},
+        {"name": "project10"},
+        {"name": "project11"},
+        {"name": "project12"},
+        {"name": "project13"},
+        {"name": "project14"},
+        {"name": "project15"},
+        {"name": "project16"},
+        {"name": "project17"},
+        {"name": "project18"},
+        {"name": "project19"},
+        {"name": "project20"},
+    ],
+    "headers": {"X-Total": "30", "x-per-page": "20"},
+    "status": 200,
+    "match": helpers.MATCH_EMPTY_QUERY_PARAMS,
+}
+
+
+@responses.activate
+def test_list_request_pagination_warning(gl):
+    responses.add(**large_list_response)
+
+    with warnings.catch_warnings(record=True) as caught_warnings:
+        result = gl.http_list("/projects", iterator=False)
+    assert len(caught_warnings) == 1
+    warning = caught_warnings[0]
+    assert isinstance(warning.message, UserWarning)
+    message = str(warning.message)
+    assert "Calling a `list()` method" in message
+    assert "python-gitlab.readthedocs.io" in message
+    assert __file__ in message
+    assert __file__ == warning.filename
+    assert isinstance(result, list)
+    assert len(result) == 20
+    assert len(responses.calls) == 1
+
+
+@responses.activate
+def test_list_request_iterator_true_nowarning(gl):
+    responses.add(**large_list_response)
+    with warnings.catch_warnings(record=True) as caught_warnings:
+        result = gl.http_list("/projects", iterator=True)
+    assert len(caught_warnings) == 0
+    assert isinstance(result, GitlabList)
+    assert len(list(result)) == 20
+    assert len(responses.calls) == 1
+
+
+@responses.activate
+def test_list_request_all_true_nowarning(gl):
+    responses.add(**large_list_response)
+    with warnings.catch_warnings(record=True) as caught_warnings:
+        result = gl.http_list("/projects", get_all=True)
+    assert len(caught_warnings) == 0
+    assert isinstance(result, list)
+    assert len(result) == 20
+    assert len(responses.calls) == 1
+
+
+@responses.activate
+def test_list_request_all_false_nowarning(gl):
+    responses.add(**large_list_response)
+    with warnings.catch_warnings(record=True) as caught_warnings:
+        result = gl.http_list("/projects", all=False)
+    assert len(caught_warnings) == 0
+    assert isinstance(result, list)
+    assert len(result) == 20
+    assert len(responses.calls) == 1
+
+
+@responses.activate
+def test_list_request_page_nowarning(gl):
+    response_dict = copy.deepcopy(large_list_response)
+    response_dict["match"] = [responses.matchers.query_param_matcher({"page": "1"})]
+    responses.add(**response_dict)
+    with warnings.catch_warnings(record=True) as caught_warnings:
+        gl.http_list("/projects", page=1)
+    assert len(caught_warnings) == 0
+    assert len(responses.calls) == 1
+
+
+@responses.activate
+def test_list_request_404(gl):
+    url = "http://localhost/api/v4/not_there"
+    responses.add(
+        method=responses.GET,
+        url=url,
+        json=[],
+        status=404,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    with pytest.raises(GitlabHttpError):
+        gl.http_list("/not_there")
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_list_request_invalid_data(gl):
+    url = "http://localhost/api/v4/projects"
+    responses.add(
+        method=responses.GET,
+        url=url,
+        body='["name": "project1"]',
+        content_type="application/json",
+        status=200,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    with pytest.raises(GitlabParsingError):
+        gl.http_list("/projects")
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_post_request(gl):
+    url = "http://localhost/api/v4/projects"
+    responses.add(
+        method=responses.POST,
+        url=url,
+        json={"name": "project1"},
+        status=200,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    result = gl.http_post("/projects")
+    assert isinstance(result, dict)
+    assert result["name"] == "project1"
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_post_request_404(gl):
+    url = "http://localhost/api/v4/not_there"
+    responses.add(
+        method=responses.POST,
+        url=url,
+        json=[],
+        status=404,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    with pytest.raises(GitlabHttpError):
+        gl.http_post("/not_there")
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_post_request_invalid_data(gl):
+    url = "http://localhost/api/v4/projects"
+    responses.add(
+        method=responses.POST,
+        url=url,
+        content_type="application/json",
+        body='["name": "project1"]',
+        status=200,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    with pytest.raises(GitlabParsingError):
+        gl.http_post("/projects")
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_put_request(gl):
+    url = "http://localhost/api/v4/projects"
+    responses.add(
+        method=responses.PUT,
+        url=url,
+        json={"name": "project1"},
+        status=200,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    result = gl.http_put("/projects")
+    assert isinstance(result, dict)
+    assert result["name"] == "project1"
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_put_request_404(gl):
+    url = "http://localhost/api/v4/not_there"
+    responses.add(
+        method=responses.PUT,
+        url=url,
+        json=[],
+        status=404,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    with pytest.raises(GitlabHttpError):
+        gl.http_put("/not_there")
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_put_request_204(gl):
+    url = "http://localhost/api/v4/projects"
+    responses.add(
+        method=responses.PUT,
+        url=url,
+        status=204,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    result = gl.http_put("/projects")
+    assert isinstance(result, requests.Response)
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_put_request_invalid_data(gl):
+    url = "http://localhost/api/v4/projects"
+    responses.add(
+        method=responses.PUT,
+        url=url,
+        body='["name": "project1"]',
+        content_type="application/json",
+        status=200,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    with pytest.raises(GitlabParsingError):
+        gl.http_put("/projects")
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_patch_request(gl):
+    url = "http://localhost/api/v4/projects"
+    responses.add(
+        method=responses.PATCH,
+        url=url,
+        json={"name": "project1"},
+        status=200,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    result = gl.http_patch("/projects")
+    assert isinstance(result, dict)
+    assert result["name"] == "project1"
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_patch_request_204(gl):
+    url = "http://localhost/api/v4/projects"
+    responses.add(
+        method=responses.PATCH,
+        url=url,
+        status=204,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    result = gl.http_patch("/projects")
+    assert isinstance(result, requests.Response)
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_patch_request_404(gl):
+    url = "http://localhost/api/v4/not_there"
+    responses.add(
+        method=responses.PATCH,
+        url=url,
+        json=[],
+        status=404,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    with pytest.raises(GitlabHttpError):
+        gl.http_patch("/not_there")
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_patch_request_invalid_data(gl):
+    url = "http://localhost/api/v4/projects"
+    responses.add(
+        method=responses.PATCH,
+        url=url,
+        body='["name": "project1"]',
+        content_type="application/json",
+        status=200,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    with pytest.raises(GitlabParsingError):
+        gl.http_patch("/projects")
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_delete_request(gl):
+    url = "http://localhost/api/v4/projects"
+    responses.add(
+        method=responses.DELETE,
+        url=url,
+        json=True,
+        status=200,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    result = gl.http_delete("/projects")
+    assert isinstance(result, requests.Response)
+    assert result.json() is True
+    assert responses.assert_call_count(url, 1) is True
+
+
+@responses.activate
+def test_delete_request_404(gl):
+    url = "http://localhost/api/v4/not_there"
+    responses.add(
+        method=responses.DELETE,
+        url=url,
+        json=[],
+        status=404,
+        match=helpers.MATCH_EMPTY_QUERY_PARAMS,
+    )
+
+    with pytest.raises(GitlabHttpError):
+        gl.http_delete("/not_there")
+    assert responses.assert_call_count(url, 1) is True
diff --git a/tests/unit/test_graphql.py b/tests/unit/test_graphql.py
new file mode 100644
index 000000000..9348dbf98
--- /dev/null
+++ b/tests/unit/test_graphql.py
@@ -0,0 +1,116 @@
+import httpx
+import pytest
+import respx
+
+import gitlab
+
+
+@pytest.fixture(scope="module")
+def api_url() -> str:
+    return "https://gitlab.example.com/api/graphql"
+
+
+@pytest.fixture
+def gl_gql() -> gitlab.GraphQL:
+    return gitlab.GraphQL("https://gitlab.example.com")
+
+
+@pytest.fixture
+def gl_async_gql() -> gitlab.AsyncGraphQL:
+    return gitlab.AsyncGraphQL("https://gitlab.example.com")
+
+
+def test_import_error_includes_message(monkeypatch: pytest.MonkeyPatch):
+    monkeypatch.setattr(gitlab.client, "_GQL_INSTALLED", False)
+    with pytest.raises(ImportError, match="GraphQL client could not be initialized"):
+        gitlab.GraphQL()
+
+
+@pytest.mark.anyio
+async def test_async_import_error_includes_message(monkeypatch: pytest.MonkeyPatch):
+    monkeypatch.setattr(gitlab.client, "_GQL_INSTALLED", False)
+    with pytest.raises(ImportError, match="GraphQL client could not be initialized"):
+        gitlab.AsyncGraphQL()
+
+
+def test_graphql_as_context_manager_exits():
+    with gitlab.GraphQL() as gl:
+        assert isinstance(gl, gitlab.GraphQL)
+
+
+@pytest.mark.anyio
+async def test_async_graphql_as_context_manager_aexits():
+    async with gitlab.AsyncGraphQL() as gl:
+        assert isinstance(gl, gitlab.AsyncGraphQL)
+
+
+def test_graphql_retries_on_429_response(
+    gl_gql: gitlab.GraphQL, respx_mock: respx.MockRouter
+):
+    url = "https://gitlab.example.com/api/graphql"
+    responses = [
+        httpx.Response(429, headers={"retry-after": "1"}),
+        httpx.Response(
+            200, json={"data": {"currentUser": {"id": "gid://gitlab/User/1"}}}
+        ),
+    ]
+    respx_mock.post(url).mock(side_effect=responses)
+    gl_gql.execute("query {currentUser {id}}")
+
+
+@pytest.mark.anyio
+async def test_async_graphql_retries_on_429_response(
+    api_url: str, gl_async_gql: gitlab.AsyncGraphQL, respx_mock: respx.MockRouter
+):
+    responses = [
+        httpx.Response(429, headers={"retry-after": "1"}),
+        httpx.Response(
+            200, json={"data": {"currentUser": {"id": "gid://gitlab/User/1"}}}
+        ),
+    ]
+    respx_mock.post(api_url).mock(side_effect=responses)
+    await gl_async_gql.execute("query {currentUser {id}}")
+
+
+def test_graphql_raises_when_max_retries_exceeded(
+    api_url: str, respx_mock: respx.MockRouter
+):
+    responses = [httpx.Response(502), httpx.Response(502), httpx.Response(502)]
+    respx_mock.post(api_url).mock(side_effect=responses)
+
+    gl_gql = gitlab.GraphQL(
+        "https://gitlab.example.com", max_retries=1, retry_transient_errors=True
+    )
+    with pytest.raises(gitlab.GitlabHttpError):
+        gl_gql.execute("query {currentUser {id}}")
+
+
+@pytest.mark.anyio
+async def test_async_graphql_raises_when_max_retries_exceeded(
+    api_url: str, respx_mock: respx.MockRouter
+):
+    responses = [httpx.Response(502), httpx.Response(502), httpx.Response(502)]
+    respx_mock.post(api_url).mock(side_effect=responses)
+
+    gl_async_gql = gitlab.AsyncGraphQL(
+        "https://gitlab.example.com", max_retries=1, retry_transient_errors=True
+    )
+    with pytest.raises(gitlab.GitlabHttpError):
+        await gl_async_gql.execute("query {currentUser {id}}")
+
+
+def test_graphql_raises_on_401_response(
+    api_url: str, gl_gql: gitlab.GraphQL, respx_mock: respx.MockRouter
+):
+    respx_mock.post(api_url).mock(return_value=httpx.Response(401))
+    with pytest.raises(gitlab.GitlabAuthenticationError):
+        gl_gql.execute("query {currentUser {id}}")
+
+
+@pytest.mark.anyio
+async def test_async_graphql_raises_on_401_response(
+    api_url: str, gl_async_gql: gitlab.AsyncGraphQL, respx_mock: respx.MockRouter
+):
+    respx_mock.post(api_url).mock(return_value=httpx.Response(401))
+    with pytest.raises(gitlab.GitlabAuthenticationError):
+        await gl_async_gql.execute("query {currentUser {id}}")
diff --git a/tests/unit/test_retry.py b/tests/unit/test_retry.py
new file mode 100644
index 000000000..811dc4249
--- /dev/null
+++ b/tests/unit/test_retry.py
@@ -0,0 +1,41 @@
+import time
+from unittest import mock
+
+import pytest
+
+from gitlab import utils
+
+
+def test_handle_retry_on_status_ignores_unknown_status_code():
+    retry = utils.Retry(max_retries=1, retry_transient_errors=True)
+    assert retry.handle_retry_on_status(418) is False
+
+
+def test_handle_retry_on_status_accepts_retry_after_header(
+    monkeypatch: pytest.MonkeyPatch,
+):
+    mock_sleep = mock.Mock()
+    monkeypatch.setattr(time, "sleep", mock_sleep)
+    retry = utils.Retry(max_retries=1)
+    headers = {"Retry-After": "1"}
+
+    assert retry.handle_retry_on_status(429, headers=headers) is True
+    assert isinstance(mock_sleep.call_args[0][0], int)
+
+
+def test_handle_retry_on_status_accepts_ratelimit_reset_header(
+    monkeypatch: pytest.MonkeyPatch,
+):
+    mock_sleep = mock.Mock()
+    monkeypatch.setattr(time, "sleep", mock_sleep)
+
+    retry = utils.Retry(max_retries=1)
+    headers = {"RateLimit-Reset": str(int(time.time() + 1))}
+
+    assert retry.handle_retry_on_status(429, headers=headers) is True
+    assert isinstance(mock_sleep.call_args[0][0], float)
+
+
+def test_handle_retry_on_status_returns_false_when_max_retries_reached():
+    retry = utils.Retry(max_retries=0)
+    assert retry.handle_retry_on_status(429) is False
diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py
new file mode 100644
index 000000000..351f6ca34
--- /dev/null
+++ b/tests/unit/test_types.py
@@ -0,0 +1,124 @@
+import pytest
+
+from gitlab import types
+
+
+class TestRequiredOptional:
+    def test_requiredoptional_empty(self):
+        b = types.RequiredOptional()
+        assert not b.required
+        assert not b.optional
+        assert not b.exclusive
+
+    def test_requiredoptional_values_no_keywords(self):
+        b = types.RequiredOptional(
+            ("required1", "required2"),
+            ("optional1", "optional2"),
+            ("exclusive1", "exclusive2"),
+        )
+        assert b.required == ("required1", "required2")
+        assert b.optional == ("optional1", "optional2")
+        assert b.exclusive == ("exclusive1", "exclusive2")
+
+    def test_requiredoptional_values_keywords(self):
+        b = types.RequiredOptional(
+            exclusive=("exclusive1", "exclusive2"),
+            optional=("optional1", "optional2"),
+            required=("required1", "required2"),
+        )
+        assert b.required == ("required1", "required2")
+        assert b.optional == ("optional1", "optional2")
+        assert b.exclusive == ("exclusive1", "exclusive2")
+
+    def test_validate_attrs_required(self):
+        data = {"required1": 1, "optional2": 2}
+        rq = types.RequiredOptional(required=("required1",))
+        rq.validate_attrs(data=data)
+        data = {"optional1": 1, "optional2": 2}
+        with pytest.raises(AttributeError, match="Missing attributes: required1"):
+            rq.validate_attrs(data=data)
+
+    def test_validate_attrs_exclusive(self):
+        data = {"exclusive1": 1, "optional1": 1}
+        rq = types.RequiredOptional(exclusive=("exclusive1", "exclusive2"))
+        rq.validate_attrs(data=data)
+        data = {"exclusive1": 1, "exclusive2": 2, "optional1": 1}
+        with pytest.raises(
+            AttributeError,
+            match="Provide only one of these attributes: exclusive1, exclusive2",
+        ):
+            rq.validate_attrs(data=data)
+
+
+def test_gitlab_attribute_get():
+    o = types.GitlabAttribute("whatever")
+    assert o.get() == "whatever"
+
+    o.set_from_cli("whatever2")
+    assert o.get() == "whatever2"
+    assert o.get_for_api(key="spam") == ("spam", "whatever2")
+
+    o = types.GitlabAttribute()
+    assert o._value is None
+
+
+def test_array_attribute_input():
+    o = types.ArrayAttribute()
+    o.set_from_cli("foo,bar,baz")
+    assert o.get() == ["foo", "bar", "baz"]
+
+    o.set_from_cli("foo")
+    assert o.get() == ["foo"]
+
+
+def test_array_attribute_empty_input():
+    o = types.ArrayAttribute()
+    o.set_from_cli("")
+    assert o.get() == []
+
+    o.set_from_cli("  ")
+    assert o.get() == []
+
+
+def test_array_attribute_get_for_api_from_cli():
+    o = types.ArrayAttribute()
+    o.set_from_cli("foo,bar,baz")
+    assert o.get_for_api(key="spam") == ("spam[]", ["foo", "bar", "baz"])
+
+
+def test_array_attribute_get_for_api_from_list():
+    o = types.ArrayAttribute(["foo", "bar", "baz"])
+    assert o.get_for_api(key="spam") == ("spam[]", ["foo", "bar", "baz"])
+
+
+def test_array_attribute_get_for_api_from_int_list():
+    o = types.ArrayAttribute([1, 9, 7])
+    assert o.get_for_api(key="spam") == ("spam[]", [1, 9, 7])
+
+
+def test_array_attribute_does_not_split_string():
+    o = types.ArrayAttribute("foo")
+    assert o.get_for_api(key="spam") == ("spam[]", "foo")
+
+
+# CommaSeparatedListAttribute tests
+def test_csv_string_attribute_get_for_api_from_cli():
+    o = types.CommaSeparatedListAttribute()
+    o.set_from_cli("foo,bar,baz")
+    assert o.get_for_api(key="spam") == ("spam", "foo,bar,baz")
+
+
+def test_csv_string_attribute_get_for_api_from_list():
+    o = types.CommaSeparatedListAttribute(["foo", "bar", "baz"])
+    assert o.get_for_api(key="spam") == ("spam", "foo,bar,baz")
+
+
+def test_csv_string_attribute_get_for_api_from_int_list():
+    o = types.CommaSeparatedListAttribute([1, 9, 7])
+    assert o.get_for_api(key="spam") == ("spam", "1,9,7")
+
+
+# LowercaseStringAttribute tests
+def test_lowercase_string_attribute_get_for_api():
+    o = types.LowercaseStringAttribute("FOO")
+    assert o.get_for_api(key="spam") == ("spam", "foo")
diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py
new file mode 100644
index 000000000..170f4cc41
--- /dev/null
+++ b/tests/unit/test_utils.py
@@ -0,0 +1,237 @@
+import json
+import logging
+import warnings
+
+import pytest
+import requests
+import responses
+
+from gitlab import types, utils
+
+
+@pytest.mark.parametrize(
+    "content_type,expected_type",
+    [
+        ("application/json", "application/json"),
+        ("application/json; charset=utf-8", "application/json"),
+        ("", "text/plain"),
+        (None, "text/plain"),
+    ],
+)
+def test_get_content_type(content_type, expected_type):
+    parsed_type = utils.get_content_type(content_type)
+    assert parsed_type == expected_type
+
+
+@responses.activate
+def test_response_content(capsys):
+    responses.add(
+        method="GET",
+        url="https://example.com",
+        status=200,
+        body="test",
+        content_type="application/octet-stream",
+    )
+
+    resp = requests.get("https://example.com", stream=True)
+    utils.response_content(
+        resp, streamed=True, action=None, chunk_size=1024, iterator=False
+    )
+
+    captured = capsys.readouterr()
+    assert "test" in captured.out
+
+
+class TestEncodedId:
+    def test_init_str(self):
+        obj = utils.EncodedId("Hello")
+        assert "Hello" == obj
+        assert "Hello" == str(obj)
+        assert "Hello" == f"{obj}"
+        assert isinstance(obj, utils.EncodedId)
+
+        obj = utils.EncodedId("this/is a/path")
+        assert "this%2Fis%20a%2Fpath" == str(obj)
+        assert "this%2Fis%20a%2Fpath" == f"{obj}"
+        assert isinstance(obj, utils.EncodedId)
+
+    def test_init_int(self):
+        obj = utils.EncodedId(23)
+        assert "23" == obj
+        assert "23" == f"{obj}"
+        assert isinstance(obj, utils.EncodedId)
+
+    def test_init_invalid_type_raises(self):
+        with pytest.raises(TypeError):
+            utils.EncodedId(None)
+
+    def test_init_encodeid_str(self):
+        value = "Goodbye"
+        obj_init = utils.EncodedId(value)
+        obj = utils.EncodedId(obj_init)
+        assert value == str(obj)
+        assert value == f"{obj}"
+
+        value = "we got/a/path"
+        expected = "we%20got%2Fa%2Fpath"
+        obj_init = utils.EncodedId(value)
+        assert expected == str(obj_init)
+        assert expected == f"{obj_init}"
+        # Show that no matter how many times we recursively call it we still only
+        # URL-encode it once.
+        obj = utils.EncodedId(
+            utils.EncodedId(utils.EncodedId(utils.EncodedId(utils.EncodedId(obj_init))))
+        )
+        assert expected == str(obj)
+        assert expected == f"{obj}"
+
+        # Show assignments still only encode once
+        obj2 = obj
+        assert expected == str(obj2)
+        assert expected == f"{obj2}"
+
+    def test_init_encodeid_int(self):
+        value = 23
+        expected = f"{value}"
+        obj_init = utils.EncodedId(value)
+        obj = utils.EncodedId(obj_init)
+        assert expected == str(obj)
+        assert expected == f"{obj}"
+
+    def test_json_serializable(self):
+        obj = utils.EncodedId("someone")
+        assert '"someone"' == json.dumps(obj)
+
+        obj = utils.EncodedId("we got/a/path")
+        assert '"we%20got%2Fa%2Fpath"' == json.dumps(obj)
+
+
+class TestWarningsWrapper:
+    def test_warn(self):
+        warn_message = "short and stout"
+        warn_source = "teapot"
+
+        with warnings.catch_warnings(record=True) as caught_warnings:
+            utils.warn(message=warn_message, category=UserWarning, source=warn_source)
+        assert len(caught_warnings) == 1
+        warning = caught_warnings[0]
+        # File name is this file as it is the first file outside of the `gitlab/` path.
+        assert __file__ == warning.filename
+        assert warning.category == UserWarning
+        assert isinstance(warning.message, UserWarning)
+        assert warn_message in str(warning.message)
+        assert __file__ in str(warning.message)
+        assert warn_source == warning.source
+
+    def test_warn_no_show_caller(self):
+        warn_message = "short and stout"
+        warn_source = "teapot"
+
+        with warnings.catch_warnings(record=True) as caught_warnings:
+            utils.warn(
+                message=warn_message,
+                category=UserWarning,
+                source=warn_source,
+                show_caller=False,
+            )
+        assert len(caught_warnings) == 1
+        warning = caught_warnings[0]
+        # File name is this file as it is the first file outside of the `gitlab/` path.
+        assert __file__ == warning.filename
+        assert warning.category == UserWarning
+        assert isinstance(warning.message, UserWarning)
+        assert warn_message in str(warning.message)
+        assert __file__ not in str(warning.message)
+        assert warn_source == warning.source
+
+
+@pytest.mark.parametrize(
+    "source,expected",
+    [
+        ({"a": "", "b": "spam", "c": None}, {"a": "", "b": "spam", "c": None}),
+        ({"a": "", "b": {"c": "spam"}}, {"a": "", "b[c]": "spam"}),
+    ],
+)
+def test_copy_dict(source, expected):
+    dest = {}
+
+    utils.copy_dict(src=source, dest=dest)
+    assert dest == expected
+
+
+@pytest.mark.parametrize(
+    "dictionary,expected",
+    [
+        ({"a": None, "b": "spam"}, {"b": "spam"}),
+        ({"a": "", "b": "spam"}, {"a": "", "b": "spam"}),
+        ({"a": None, "b": None}, {}),
+    ],
+)
+def test_remove_none_from_dict(dictionary, expected):
+    result = utils.remove_none_from_dict(dictionary)
+    assert result == expected
+
+
+def test_transform_types_copies_data_with_empty_files():
+    data = {"attr": "spam"}
+    new_data, files = utils._transform_types(data, {}, transform_data=True)
+
+    assert new_data is not data
+    assert new_data == data
+    assert files == {}
+
+
+def test_transform_types_with_transform_files_populates_files():
+    custom_types = {"attr": types.FileAttribute}
+    data = {"attr": "spam"}
+    new_data, files = utils._transform_types(data, custom_types, transform_data=True)
+
+    assert new_data == {}
+    assert files["attr"] == ("attr", "spam")
+
+
+def test_transform_types_without_transform_files_populates_data_with_empty_files():
+    custom_types = {"attr": types.FileAttribute}
+    data = {"attr": "spam"}
+    new_data, files = utils._transform_types(
+        data, custom_types, transform_files=False, transform_data=True
+    )
+
+    assert new_data == {"attr": "spam"}
+    assert files == {}
+
+
+def test_transform_types_params_array():
+    data = {"attr": [1, 2, 3]}
+    custom_types = {"attr": types.ArrayAttribute}
+    new_data, files = utils._transform_types(data, custom_types, transform_data=True)
+
+    assert new_data is not data
+    assert new_data == {"attr[]": [1, 2, 3]}
+    assert files == {}
+
+
+def test_transform_types_not_params_array():
+    data = {"attr": [1, 2, 3]}
+    custom_types = {"attr": types.ArrayAttribute}
+    new_data, files = utils._transform_types(data, custom_types, transform_data=False)
+
+    assert new_data is not data
+    assert new_data == data
+    assert files == {}
+
+
+def test_masking_formatter_masks_token(capsys: pytest.CaptureFixture):
+    token = "glpat-private-token"
+
+    logger = logging.getLogger()
+    handler = logging.StreamHandler()
+    handler.setFormatter(utils.MaskingFormatter(masked=token))
+    logger.handlers.clear()
+    logger.addHandler(handler)
+
+    logger.info(token)
+    captured = capsys.readouterr()
+
+    assert "[MASKED]" in captured.err
+    assert token not in captured.err
diff --git a/tools/build_test_env.sh b/tools/build_test_env.sh
deleted file mode 100755
index 3f6191a2a..000000000
--- a/tools/build_test_env.sh
+++ /dev/null
@@ -1,151 +0,0 @@
-#!/bin/sh
-# Copyright (C) 2016 Gauvain Pocentek <gauvain@pocentek.net>
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Lesser General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Lesser General Public License for more details.
-#
-# You should have received a copy of the GNU Lesser General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-pecho() { printf %s\\n "$*"; }
-log() {
-    [ "$#" -eq 0 ] || { pecho "$@"; return 0; }
-    while IFS= read -r log_line || [ -n "${log_line}" ]; do
-        log "${log_line}"
-    done
-}
-error() { log "ERROR: $@" >&2; }
-fatal() { error "$@"; exit 1; }
-try() { "$@" || fatal "'$@' failed"; }
-
-PY_VER=2
-while getopts :p: opt "$@"; do
-    case $opt in
-        p) PY_VER=$OPTARG;;
-        :) fatal "Option -${OPTARG} requires a value";;
-        '?') fatal "Unknown option: -${OPTARG}";;
-        *) fatal "Internal error: opt=${opt}";;
-    esac
-done
-
-case $PY_VER in
-    2) VENV_CMD=virtualenv;;
-    3) VENV_CMD=pyvenv;;
-    *) fatal "Wrong python version (2 or 3)";;
-esac
-
-for req in \
-    curl \
-    docker \
-    "${VENV_CMD}" \
-    ;
-do
-    command -v "${req}" >/dev/null 2>&1 || fatal "${req} is required"
-done
-
-VENV=$(pwd)/.venv || exit 1
-CONFIG=/tmp/python-gitlab.cfg
-
-cleanup() {
-    rm -f "${CONFIG}"
-    log "Stopping gitlab-test docker container..."
-    docker stop gitlab-test >/dev/null &
-    docker_stop_pid=$!
-    log "Waiting for gitlab-test docker container to exit..."
-    docker wait gitlab-test >/dev/null
-    wait "${docker_stop_pid}"
-    log "Removing gitlab-test docker container..."
-    docker rm gitlab-test >/dev/null
-    log "Deactivating Python virtualenv..."
-    command -v deactivate >/dev/null 2>&1 && deactivate || true
-    log "Deleting python virtualenv..."
-    rm -rf "$VENV"
-    log "Done."
-}
-[ -z "${BUILD_TEST_ENV_AUTO_CLEANUP+set}" ] || {
-    trap cleanup EXIT
-    trap 'exit 1' HUP INT TERM
-}
-
-try docker run --name gitlab-test --detach --publish 8080:80 \
-    --publish 2222:22 gpocentek/test-python-gitlab:latest >/dev/null
-
-LOGIN='root'
-PASSWORD='5iveL!fe'
-GITLAB() { gitlab --config-file "$CONFIG" "$@"; }
-GREEN='\033[0;32m'
-NC='\033[0m'
-OK() { printf "${GREEN}OK${NC}\\n"; }
-testcase() {
-    testname=$1; shift
-    testscript=$1; shift
-    printf %s "Testing ${testname}... "
-    eval "${testscript}" || fatal "test failed"
-    OK
-}
-
-log "Waiting for gitlab to come online... "
-I=0
-while :; do
-    sleep 1
-    docker top gitlab-test >/dev/null 2>&1 || fatal "docker failed to start"
-    sleep 4
-    curl -s http://localhost:8080/users/sign_in 2>/dev/null \
-        | grep -q "GitLab Community Edition" && break
-    I=$((I+5))
-    [ "$I" -lt 120 ] || fatal "timed out"
-done
-
-# Get the token
-log "Getting GitLab token..."
-I=0
-while :; do
-    sleep 1
-    TOKEN_JSON=$(
-        try curl -s http://localhost:8080/api/v3/session \
-            -X POST \
-            --data "login=$LOGIN&password=$PASSWORD"
-    ) >/dev/null 2>&1 || true
-    TOKEN=$(
-        pecho "${TOKEN_JSON}" |
-        try python -c \
-            'import sys, json; print(json.load(sys.stdin)["private_token"])'
-    ) >/dev/null 2>&1 && break
-    I=$((I+1))
-    [ "$I" -lt 20 ] || fatal "timed out"
-done
-
-cat > $CONFIG << EOF
-[global]
-default = local
-timeout = 10
-
-[local]
-url = http://localhost:8080
-private_token = $TOKEN
-EOF
-
-log "Config file content ($CONFIG):"
-log <$CONFIG
-
-log "Creating Python virtualenv..."
-try "$VENV_CMD" "$VENV"
-. "$VENV"/bin/activate || fatal "failed to activate Python virtual environment"
-
-log "Installing dependencies into virtualenv..."
-try pip install -rrequirements.txt
-
-log "Installing into virtualenv..."
-try pip install -e .
-
-log "Pausing to give GitLab some time to finish starting up..."
-sleep 20
-
-log "Test environment initialized."
diff --git a/tools/functional_tests.sh b/tools/functional_tests.sh
deleted file mode 100755
index a4a8d06c7..000000000
--- a/tools/functional_tests.sh
+++ /dev/null
@@ -1,107 +0,0 @@
-#!/bin/sh
-# Copyright (C) 2015 Gauvain Pocentek <gauvain@pocentek.net>
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Lesser General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Lesser General Public License for more details.
-#
-# You should have received a copy of the GNU Lesser General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-setenv_script=$(dirname "$0")/build_test_env.sh || exit 1
-BUILD_TEST_ENV_AUTO_CLEANUP=true
-. "$setenv_script" "$@" || exit 1
-
-testcase "project creation" '
-    OUTPUT=$(try GITLAB project create --name test-project1) || exit 1
-    PROJECT_ID=$(pecho "${OUTPUT}" | grep ^id: | cut -d" " -f2)
-    OUTPUT=$(try GITLAB project list) || exit 1
-    pecho "${OUTPUT}" | grep -q test-project1
-'
-
-testcase "project update" '
-    GITLAB project update --id "$PROJECT_ID" --description "My New Description"
-'
-
-testcase "user creation" '
-    OUTPUT=$(GITLAB user create --email fake@email.com --username user1 \
-        --name "User One" --password fakepassword)
-'
-USER_ID=$(pecho "${OUTPUT}" | grep ^id: | cut -d' ' -f2)
-
-testcase "user get (by id)" '
-    GITLAB user get --id $USER_ID >/dev/null 2>&1
-'
-
-testcase "user get (by username)" '
-    GITLAB user get-by-username --query user1 >/dev/null 2>&1
-'
-
-testcase "verbose output" '
-    OUTPUT=$(try GITLAB -v user list) || exit 1
-    pecho "${OUTPUT}" | grep -q avatar-url
-'
-
-testcase "CLI args not in output" '
-    OUTPUT=$(try GITLAB -v user list) || exit 1
-    pecho "${OUTPUT}" | grep -qv config-file
-'
-
-testcase "adding member to a project" '
-    GITLAB project-member create --project-id "$PROJECT_ID" \
-        --user-id "$USER_ID" --access-level 40 >/dev/null 2>&1
-'
-
-testcase "file creation" '
-    GITLAB project-file create --project-id "$PROJECT_ID" \
-        --file-path README --branch-name master --content "CONTENT" \
-        --commit-message "Initial commit" >/dev/null 2>&1
-'
-
-testcase "issue creation" '
-    OUTPUT=$(GITLAB project-issue create --project-id "$PROJECT_ID" \
-        --title "my issue" --description "my issue description")
-'
-ISSUE_ID=$(pecho "${OUTPUT}" | grep ^id: | cut -d' ' -f2)
-
-testcase "note creation" '
-    GITLAB project-issue-note create --project-id "$PROJECT_ID" \
-        --issue-id "$ISSUE_ID" --body "the body" >/dev/null 2>&1
-'
-
-testcase "branch creation" '
-    GITLAB project-branch create --project-id "$PROJECT_ID" \
-        --branch-name branch1 --ref master >/dev/null 2>&1
-'
-
-GITLAB project-file create --project-id "$PROJECT_ID" \
-    --file-path README2 --branch-name branch1 --content "CONTENT" \
-    --commit-message "second commit" >/dev/null 2>&1
-
-testcase "merge request creation" '
-    OUTPUT=$(GITLAB project-merge-request create \
-        --project-id "$PROJECT_ID" \
-        --source-branch branch1 --target-branch master \
-        --title "Update README")
-'
-MR_ID=$(pecho "${OUTPUT}" | grep ^id: | cut -d' ' -f2)
-
-testcase "merge request validation" '
-    GITLAB project-merge-request merge --project-id "$PROJECT_ID" \
-        --id "$MR_ID" >/dev/null 2>&1
-'
-
-testcase "branch deletion" '
-    GITLAB project-branch delete --project-id "$PROJECT_ID" \
-        --name branch1 >/dev/null 2>&1
-'
-
-testcase "project deletion" '
-    GITLAB project delete --id "$PROJECT_ID"
-'
diff --git a/tools/py_functional_tests.sh b/tools/py_functional_tests.sh
deleted file mode 100755
index 0d00c5fdf..000000000
--- a/tools/py_functional_tests.sh
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/bin/sh
-# Copyright (C) 2015 Gauvain Pocentek <gauvain@pocentek.net>
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Lesser General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Lesser General Public License for more details.
-#
-# You should have received a copy of the GNU Lesser General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-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
diff --git a/tools/python_test.py b/tools/python_test.py
deleted file mode 100644
index abfa5087b..000000000
--- a/tools/python_test.py
+++ /dev/null
@@ -1,306 +0,0 @@
-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")
-
-# 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.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.search('foobar') == [foobar_user]
-usercmp = lambda x,y: cmp(x.id, y.id)
-expected = sorted([new_user, foobar_user], cmp=usercmp)
-actual = sorted(gl.users.search('foo'), cmp=usercmp)
-assert expected == actual
-assert gl.users.search('asdf') == []
-
-assert gl.users.get_by_username('foobar') == foobar_user
-assert gl.users.get_by_username('foo') == new_user
-try:
-    gl.users.get_by_username('asdf')
-except gitlab.GitlabGetError:
-    pass
-else:
-    assert False
-
-# 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()) == 1)
-
-# current user key
-key = gl.user.keys.create({'title': 'testkey', 'key': SSH_KEY})
-assert(len(gl.user.keys.list()) == 1)
-key.delete()
-
-# 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'})
-
-assert(len(gl.groups.list()) == 2)
-assert(len(gl.groups.search("1")) == 1)
-
-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.all()) == 4)
-assert(len(gl.projects.owned()) == 2)
-assert(len(gl.projects.search("admin")) == 1)
-
-# test pagination
-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_name': '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_name="master", commit_message="new commit")
-readme.delete(commit_message="Removing README")
-
-admin_project.files.create({'file_path': 'README.rst',
-                            'branch_name': 'master',
-                            'content': 'Initial content',
-                            'commit_message': 'New commit'})
-readme = admin_project.files.get(file_path='README.rst', ref='master')
-assert(readme.decode() == 'Initial content')
-
-tree = admin_project.repository_tree()
-assert(len(tree) == 1)
-assert(tree[0]['name'] == 'README.rst')
-blob = admin_project.repository_blob('master', 'README.rst')
-assert(blob == 'Initial content')
-archive1 = admin_project.repository_archive()
-archive2 = admin_project.repository_archive('master')
-assert(archive1 == archive2)
-
-# 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()[0].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({})
-assert(len(admin_project.triggers.list()) == 1)
-tr1 = admin_project.triggers.get(tr1.token)
-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_name': 'branch1',
-                                          'ref': 'master'})
-admin_project.files.create({'file_path': 'README2.rst',
-                            'branch_name': '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'})
-ret = mr.merge()
-admin_project.branches.delete('branch1')
-
-try:
-    mr.merge()
-except gitlab.GitlabMRClosedError:
-    pass
-
-# stars
-admin_project = admin_project.star()
-assert(admin_project.star_count == 1)
-admin_project = 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()
-assert(len(ns) != 0)
-ns = gl.namespaces.list(search='root')[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()[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)
-
-# snippets
-snippets = gl.snippets.list()
-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.raw()
-assert(content == 'import gitlab')
-snippet.delete()
-assert(len(gl.snippets.list()) == 0)
diff --git a/tox.ini b/tox.ini
index ef3e68a9c..05a15c6c4 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,39 +1,156 @@
 [tox]
-minversion = 1.6
+minversion = 4.0
 skipsdist = True
-envlist = py35,py34,py27,pep8
+skip_missing_interpreters = True
+envlist = py313,py312,py311,py310,py39,black,isort,flake8,mypy,twine-check,cz,pylint
+
+# NOTE(jlvillal): To use a label use the `-m` flag.
+# For example to run the `func` label group of environments do:
+#   tox -m func
+labels =
+    lint = black,isort,flake8,mypy,pylint,cz
+    unit = py313,py312,py311,py310,py39,py38
+# func is the functional tests. This is very time consuming.
+    func = cli_func_v4,api_func_v4
 
 [testenv]
-setenv = VIRTUAL_ENV={envdir}
+passenv =
+  DOCKER_HOST
+  FORCE_COLOR
+  GITHUB_ACTIONS
+  GITHUB_WORKSPACE
+  GITLAB_IMAGE
+  GITLAB_TAG
+  GITLAB_RUNNER_IMAGE
+  GITLAB_RUNNER_TAG
+  NO_COLOR
+  PWD
+  PY_COLORS
+setenv = 
+  DOCS_SOURCE = docs
+  DOCS_BUILD = build/sphinx/html
+  VIRTUAL_ENV={envdir}
 whitelist_externals = true
 usedevelop = True
-install_command = pip install {opts} {packages}
+install_command = pip install {opts} {packages} -e .
+isolated_build = True
 
 deps = -r{toxinidir}/requirements.txt
-       -r{toxinidir}/test-requirements.txt
+       -r{toxinidir}/requirements-test.txt
+commands =
+  pytest tests/unit {posargs}
+
+[testenv:black]
+basepython = python3
+deps = -r{toxinidir}/requirements-lint.txt
+commands =
+  black {posargs} .
+
+[testenv:isort]
+basepython = python3
+deps = -r{toxinidir}/requirements-lint.txt
+commands =
+  isort {posargs} {toxinidir}
+
+[testenv:mypy]
+basepython = python3
+deps = -r{toxinidir}/requirements-lint.txt
+commands =
+  mypy {posargs}
+
+[testenv:flake8]
+basepython = python3
+deps = -r{toxinidir}/requirements-lint.txt
 commands =
-  python setup.py testr --testr-args='{posargs}'
+  flake8 {posargs} .
 
-[testenv:pep8]
+[testenv:pylint]
+basepython = python3
+deps = -r{toxinidir}/requirements-lint.txt
 commands =
-  flake8 {posargs} gitlab/
+  pylint {posargs} gitlab/
+
+[testenv:cz]
+basepython = python3
+deps = -r{toxinidir}/requirements-lint.txt
+commands =
+  cz check --rev-range 65ecadc..HEAD  # cz is fast, check from first valid commit
+
+[testenv:twine-check]
+basepython = python3
+deps = -r{toxinidir}/requirements.txt
+       build
+       twine
+commands =
+  python -m build
+  twine check dist/*
 
 [testenv:venv]
 commands = {posargs}
 
 [flake8]
 exclude = .git,.venv,.tox,dist,doc,*egg,build,
-ignore = H501,H803
+max-line-length = 88
+# We ignore the following because we use black to handle code-formatting
+# E203: Whitespace before ':'
+# E501: Line too long
+# E701: multiple statements on one line (colon)
+# E704: multiple statements on one line (def)
+# W503: Line break occurred before a binary operator
+extend-ignore = E203,E501,E701,E704,W503
+per-file-ignores =
+    gitlab/v4/objects/__init__.py:F401,F403
 
 [testenv:docs]
-commands = python setup.py build_sphinx
+description = Builds the docs site. Generated HTML files will be available in '{env:DOCS_BUILD}'. 
+deps = -r{toxinidir}/requirements-docs.txt
+commands = sphinx-build -n -W --keep-going -b html {env:DOCS_SOURCE} {env:DOCS_BUILD}
+
+[testenv:docs-serve]
+description = 
+    Builds and serves the HTML docs site locally. \
+    Use this for verifying updates to docs. \
+    Changes to docs files will be automatically rebuilt and served.
+deps = -r{toxinidir}/requirements-docs.txt
+commands = sphinx-autobuild {env:DOCS_SOURCE} {env:DOCS_BUILD} --open-browser --port 8000
 
 [testenv:cover]
 commands =
-   python setup.py testr --slowest --coverage --testr-args="{posargs}"
+  pytest --cov --cov-report term --cov-report html \
+    --cov-report xml tests/unit {posargs}
 
-[testenv:cli_func]
-commands = {toxinidir}/tools/functional_tests.sh
+[coverage:run]
+omit = *tests*
+source = gitlab
+
+[coverage:report]
+exclude_lines =
+  pragma: no cover
+  if TYPE_CHECKING:
+  if debug:
+  return NotImplemented
+
+[testenv:cli_func_v4]
+deps = -r{toxinidir}/requirements-docker.txt
+commands =
+  pytest --script-launch-mode=subprocess --cov --cov-report xml tests/functional/cli {posargs}
 
-[testenv:py_func]
-commands = {toxinidir}/tools/py_functional_tests.sh
+[testenv:api_func_v4]
+deps = -r{toxinidir}/requirements-docker.txt
+commands =
+  pytest --cov --cov-report xml tests/functional/api {posargs}
+
+[testenv:smoke]
+deps = -r{toxinidir}/requirements-test.txt
+commands = pytest tests/smoke {posargs}
+
+[testenv:pre-commit]
+skip_install = true
+deps = -r requirements-precommit.txt
+commands = pre-commit run --all-files --show-diff-on-failure
+
+[testenv:install]
+skip_install = true
+deps = -r{toxinidir}/requirements.txt
+       -r{toxinidir}/requirements-test.txt
+commands = pytest tests/install