diff --git a/.dockerignore b/.dockerignore
index e63c0c1844fda5ac3ba4a5c4d34d222908d62d33..02c7fe526bff11ba557c86fe9a5d48a668bf11d3 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -2,3 +2,4 @@
 !.coveragerc
 !.env
 !.pylintrc
+*.pyc
diff --git a/config/settings/base.py b/config/settings/base.py
index b1b03d97ca0b96827d2a5417864e389c584ae1ef..5e76b09fb5f8d3d4cce83ff53cc9d98ce950f324 100644
--- a/config/settings/base.py
+++ b/config/settings/base.py
@@ -4,32 +4,36 @@ Base settings to build other settings files upon.
 
 import environ
 
-ROOT_DIR = environ.Path(__file__) - 3  # (contributions/config/settings/base.py - 3 = contributions/)
-APPS_DIR = ROOT_DIR.path('contributions')
+ROOT_DIR = (
+    environ.Path(__file__) - 3
+)  # (contributions/config/settings/base.py - 3 = contributions/)
+APPS_DIR = ROOT_DIR.path("contributions")
 
 env = environ.Env()
 
-READ_DOT_ENV_FILE = env.bool('DJANGO_READ_DOT_ENV_FILE', default=False)
+READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=False)
 if READ_DOT_ENV_FILE:
     # OS environment variables take precedence over variables from .env
-    env.read_env(str(ROOT_DIR.path('.env')))
+    env.read_env(str(ROOT_DIR.path(".env")))
 
 
-SECRET_KEY = env('DJANGO_SECRET_KEY')
-ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS', default=['contributions.funkwhale.audio'])
+SECRET_KEY = env("DJANGO_SECRET_KEY")
+ALLOWED_HOSTS = env.list(
+    "DJANGO_ALLOWED_HOSTS", default=["contributions.funkwhale.audio"]
+)
 
 
 # GENERAL
 # ------------------------------------------------------------------------------
 # https://docs.djangoproject.com/en/dev/ref/settings/#debug
-DEBUG = env.bool('DJANGO_DEBUG', False)
+DEBUG = env.bool("DJANGO_DEBUG", False)
 # Local time zone. Choices are
 # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
 # though not all of them may be available with every OS.
 # In Windows, this must be set to your system time zone.
-TIME_ZONE = 'UTC'
+TIME_ZONE = "UTC"
 # https://docs.djangoproject.com/en/dev/ref/settings/#language-code
-LANGUAGE_CODE = 'en-us'
+LANGUAGE_CODE = "en-us"
 # https://docs.djangoproject.com/en/dev/ref/settings/#site-id
 SITE_ID = 1
 # https://docs.djangoproject.com/en/dev/ref/settings/#use-i18n
@@ -42,17 +46,15 @@ USE_TZ = True
 # DATABASES
 # ------------------------------------------------------------------------------
 # https://docs.djangoproject.com/en/dev/ref/settings/#databases
-DATABASES = {
-    'default': env.db('DATABASE_URL'),
-}
-DATABASES['default']['ATOMIC_REQUESTS'] = True
-DATABASES['default']['CONN_MAX_AGE'] = env.int('CONN_MAX_AGE', default=60)  # noqa F405
+DATABASES = {"default": env.db("DATABASE_URL")}
+DATABASES["default"]["ATOMIC_REQUESTS"] = True
+DATABASES["default"]["CONN_MAX_AGE"] = env.int("CONN_MAX_AGE", default=60)  # noqa F405
 
 
 CACHES = {
-    'default': {
-        'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
-        'LOCATION': ''
+    "default": {
+        "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
+        "LOCATION": "",
     }
 }
 
@@ -60,113 +62,100 @@ CACHES = {
 # URLS
 # ------------------------------------------------------------------------------
 # https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf
-ROOT_URLCONF = 'config.urls'
+ROOT_URLCONF = "config.urls"
 # https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application
-WSGI_APPLICATION = 'config.wsgi.application'
+WSGI_APPLICATION = "config.wsgi.application"
 
 # APPS
 # ------------------------------------------------------------------------------
 DJANGO_APPS = [
-    'django.contrib.auth',
-    'django.contrib.contenttypes',
-    'django.contrib.sessions',
-    'django.contrib.sites',
-    'django.contrib.messages',
-    'django.contrib.staticfiles',
+    "django.contrib.auth",
+    "django.contrib.contenttypes",
+    "django.contrib.sessions",
+    "django.contrib.sites",
+    "django.contrib.messages",
+    "django.contrib.staticfiles",
     # 'django.contrib.humanize', # Handy template tags
-    'django.contrib.admin',
+    "django.contrib.admin",
 ]
 THIRD_PARTY_APPS = [
-    'allauth',
-    'allauth.account',
-    'allauth.socialaccount',
-    'rest_framework',
+    "allauth",
+    "allauth.account",
+    "allauth.socialaccount",
+    "rest_framework",
 ]
 LOCAL_APPS = [
-    'contributions.users.apps.UsersAppConfig',
+    "contributions.users.apps.UsersAppConfig",
     # Your stuff: custom apps go here
+    "contributions.core",
 ]
 # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
 INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
 
-# MIGRATIONS
-# ------------------------------------------------------------------------------
-# https://docs.djangoproject.com/en/dev/ref/settings/#migration-modules
-MIGRATION_MODULES = {
-    'sites': 'contributions.contrib.sites.migrations'
-}
 
 # AUTHENTICATION
 # ------------------------------------------------------------------------------
 # https://docs.djangoproject.com/en/dev/ref/settings/#authentication-backends
 AUTHENTICATION_BACKENDS = [
-    'django.contrib.auth.backends.ModelBackend',
-    'allauth.account.auth_backends.AuthenticationBackend',
+    "django.contrib.auth.backends.ModelBackend",
+    "allauth.account.auth_backends.AuthenticationBackend",
 ]
 # https://docs.djangoproject.com/en/dev/ref/settings/#auth-user-model
-AUTH_USER_MODEL = 'users.User'
+AUTH_USER_MODEL = "users.User"
 # https://docs.djangoproject.com/en/dev/ref/settings/#login-redirect-url
-LOGIN_REDIRECT_URL = 'users:redirect'
+LOGIN_REDIRECT_URL = "users:redirect"
 # https://docs.djangoproject.com/en/dev/ref/settings/#login-url
-LOGIN_URL = 'account_login'
+LOGIN_URL = "account_login"
 
 # PASSWORDS
 # ------------------------------------------------------------------------------
 # https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers
 PASSWORD_HASHERS = [
-    'django.contrib.auth.hashers.PBKDF2PasswordHasher',
-    'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
-    'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
-    'django.contrib.auth.hashers.BCryptPasswordHasher',
+    "django.contrib.auth.hashers.PBKDF2PasswordHasher",
+    "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
+    "django.contrib.auth.hashers.BCryptSHA256PasswordHasher",
+    "django.contrib.auth.hashers.BCryptPasswordHasher",
 ]
 # https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators
 AUTH_PASSWORD_VALIDATORS = [
     {
-        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
-    },
-    {
-        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
-    },
-    {
-        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
-    },
-    {
-        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+        "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
     },
+    {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
+    {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
+    {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
 ]
 
 # MIDDLEWARE
 # ------------------------------------------------------------------------------
 # https://docs.djangoproject.com/en/dev/ref/settings/#middleware
 MIDDLEWARE = [
-    'django.middleware.security.SecurityMiddleware',
-    'django.contrib.sessions.middleware.SessionMiddleware',
-    'django.middleware.common.CommonMiddleware',
-    'django.middleware.csrf.CsrfViewMiddleware',
-    'django.contrib.auth.middleware.AuthenticationMiddleware',
-    'django.contrib.messages.middleware.MessageMiddleware',
-    'django.middleware.clickjacking.XFrameOptionsMiddleware',
+    "django.middleware.security.SecurityMiddleware",
+    "django.contrib.sessions.middleware.SessionMiddleware",
+    "django.middleware.common.CommonMiddleware",
+    "django.middleware.csrf.CsrfViewMiddleware",
+    "django.contrib.auth.middleware.AuthenticationMiddleware",
+    "django.contrib.messages.middleware.MessageMiddleware",
+    "django.middleware.clickjacking.XFrameOptionsMiddleware",
 ]
 
 # STATIC
 # ------------------------------------------------------------------------------
-STATIC_URL = env('STATIC_URL', default='/static/')
-STATIC_ROOT = env('STATIC_ROOT', default=str(ROOT_DIR('staticfiles')))
+STATIC_URL = env("STATIC_URL", default="/static/")
+STATIC_ROOT = env("STATIC_ROOT", default=str(ROOT_DIR("staticfiles")))
 
 # https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS
-STATICFILES_DIRS = [
-    str(APPS_DIR.path('static')),
-]
+STATICFILES_DIRS = []
 # https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#staticfiles-finders
 STATICFILES_FINDERS = [
-    'django.contrib.staticfiles.finders.FileSystemFinder',
-    'django.contrib.staticfiles.finders.AppDirectoriesFinder',
+    "django.contrib.staticfiles.finders.FileSystemFinder",
+    "django.contrib.staticfiles.finders.AppDirectoriesFinder",
 ]
 
 # MEDIA
 # ------------------------------------------------------------------------------
-MEDIA_URL = env('MEDIA_URL', default='/media/')
-MEDIA_ROOT = env('MEDIA_ROOT', default=str(APPS_DIR('media')))
+MEDIA_URL = env("MEDIA_URL", default="/media/")
+MEDIA_ROOT = env("MEDIA_ROOT", default=str(APPS_DIR("media")))
 
 # TEMPLATES
 # ------------------------------------------------------------------------------
@@ -174,71 +163,69 @@ MEDIA_ROOT = env('MEDIA_ROOT', default=str(APPS_DIR('media')))
 TEMPLATES = [
     {
         # https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-TEMPLATES-BACKEND
-        'BACKEND': 'django.template.backends.django.DjangoTemplates',
+        "BACKEND": "django.template.backends.django.DjangoTemplates",
         # https://docs.djangoproject.com/en/dev/ref/settings/#template-dirs
-        'DIRS': [
-            str(APPS_DIR.path('templates')),
-        ],
-        'OPTIONS': {
+        "DIRS": [str(APPS_DIR.path("templates"))],
+        "OPTIONS": {
             # https://docs.djangoproject.com/en/dev/ref/settings/#template-debug
-            'debug': DEBUG,
+            "debug": DEBUG,
             # https://docs.djangoproject.com/en/dev/ref/settings/#template-loaders
             # https://docs.djangoproject.com/en/dev/ref/templates/api/#loader-types
-            'loaders': [
-                'django.template.loaders.filesystem.Loader',
-                'django.template.loaders.app_directories.Loader',
+            "loaders": [
+                "django.template.loaders.filesystem.Loader",
+                "django.template.loaders.app_directories.Loader",
             ],
             # https://docs.djangoproject.com/en/dev/ref/settings/#template-context-processors
-            'context_processors': [
-                'django.template.context_processors.debug',
-                'django.template.context_processors.request',
-                'django.contrib.auth.context_processors.auth',
-                'django.template.context_processors.i18n',
-                'django.template.context_processors.media',
-                'django.template.context_processors.static',
-                'django.template.context_processors.tz',
-                'django.contrib.messages.context_processors.messages',
+            "context_processors": [
+                "django.template.context_processors.debug",
+                "django.template.context_processors.request",
+                "django.contrib.auth.context_processors.auth",
+                "django.template.context_processors.i18n",
+                "django.template.context_processors.media",
+                "django.template.context_processors.static",
+                "django.template.context_processors.tz",
+                "django.contrib.messages.context_processors.messages",
             ],
         },
-    },
+    }
 ]
-TEMPLATES[0]['OPTIONS']['debug'] = DEBUG  # noqa F405
+TEMPLATES[0]["OPTIONS"]["debug"] = DEBUG  # noqa F405
 
 # FIXTURES
 # ------------------------------------------------------------------------------
 # https://docs.djangoproject.com/en/dev/ref/settings/#fixture-dirs
-FIXTURE_DIRS = (
-    str(APPS_DIR.path('fixtures')),
-)
+FIXTURE_DIRS = (str(APPS_DIR.path("fixtures")),)
 
 # EMAIL
 # ------------------------------------------------------------------------------
 # https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
-EMAIL_BACKEND = env('DJANGO_EMAIL_BACKEND', default='django.core.mail.backends.console.EmailBackend')
+EMAIL_BACKEND = env(
+    "DJANGO_EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend"
+)
 DEFAULT_FROM_EMAIL = env(
-    'DJANGO_DEFAULT_FROM_EMAIL',
-    default='Contributions <noreply@contributions.funkwhale.audio>'
+    "DJANGO_DEFAULT_FROM_EMAIL",
+    default="Contributions <noreply@contributions.funkwhale.audio>",
 )
-SERVER_EMAIL = env('DJANGO_SERVER_EMAIL', default=DEFAULT_FROM_EMAIL)
+SERVER_EMAIL = env("DJANGO_SERVER_EMAIL", default=DEFAULT_FROM_EMAIL)
 # https://docs.djangoproject.com/en/dev/ref/settings/#email-subject-prefix
-EMAIL_SUBJECT_PREFIX = env('DJANGO_EMAIL_SUBJECT_PREFIX', default='[Contributions]')
+EMAIL_SUBJECT_PREFIX = env("DJANGO_EMAIL_SUBJECT_PREFIX", default="[Contributions]")
 
 # https://docs.djangoproject.com
 # ADMIN
 # ------------------------------------------------------------------------------
 # Django Admin URL.
-ADMIN_URL = env('DJANGO_ADMIN_URL', default='admin/')
+ADMIN_URL = env("DJANGO_ADMIN_URL", default="admin/")
 
 # django-allauth
 # ------------------------------------------------------------------------------
-ACCOUNT_ALLOW_REGISTRATION = env.bool('DJANGO_ACCOUNT_ALLOW_REGISTRATION', False)
+ACCOUNT_ALLOW_REGISTRATION = env.bool("DJANGO_ACCOUNT_ALLOW_REGISTRATION", False)
 # https://django-allauth.readthedocs.io/en/latest/configuration.html
-ACCOUNT_AUTHENTICATION_METHOD = 'username'
+ACCOUNT_AUTHENTICATION_METHOD = "username"
 # https://django-allauth.readthedocs.io/en/latest/configuration.html
 ACCOUNT_EMAIL_REQUIRED = True
 # https://django-allauth.readthedocs.io/en/latest/configuration.html
-ACCOUNT_EMAIL_VERIFICATION = 'mandatory'
+ACCOUNT_EMAIL_VERIFICATION = "mandatory"
 # https://django-allauth.readthedocs.io/en/latest/configuration.html
-ACCOUNT_ADAPTER = 'contributions.users.adapters.AccountAdapter'
+ACCOUNT_ADAPTER = "contributions.users.adapters.AccountAdapter"
 # https://django-allauth.readthedocs.io/en/latest/configuration.html
-SOCIALACCOUNT_ADAPTER = 'contributions.users.adapters.SocialAccountAdapter'
+SOCIALACCOUNT_ADAPTER = "contributions.users.adapters.SocialAccountAdapter"
diff --git a/config/urls.py b/config/urls.py
index c140490b381470373f4a324292e2ae6aecfc11a8..322ca7b959292931a44764c036b8e66a151c1f6e 100644
--- a/config/urls.py
+++ b/config/urls.py
@@ -5,23 +5,7 @@ from django.contrib import admin
 from django.views.generic import TemplateView
 from django.views import defaults as default_views
 
-urlpatterns = [
-    path("", TemplateView.as_view(template_name="pages/home.html"), name="home"),
-    path(
-        "about/",
-        TemplateView.as_view(template_name="pages/about.html"),
-        name="about",
-    ),
-    # Django Admin, use {% url 'admin:index' %}
-    path(settings.ADMIN_URL, admin.site.urls),
-    # User management
-    path(
-        "users/",
-        include("contributions.users.urls", namespace="users"),
-    ),
-    path("accounts/", include("allauth.urls")),
-    # Your stuff: custom urls includes go here
-] + static(
+urlpatterns = [path(settings.ADMIN_URL, admin.site.urls)] + static(
     settings.MEDIA_URL, document_root=settings.MEDIA_ROOT
 )
 
diff --git a/contributions/core/__init__.py b/contributions/core/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/contributions/core/admin.py b/contributions/core/admin.py
new file mode 100644
index 0000000000000000000000000000000000000000..224ca306530f7399329bd6f186f7fd84abc9d23b
--- /dev/null
+++ b/contributions/core/admin.py
@@ -0,0 +1,31 @@
+from django.contrib import admin
+
+from . import models
+
+
+@admin.register(models.Contributor)
+class ContributorAdmin(admin.ModelAdmin):
+    list_display = ["username", "name", "creation_date"]
+    search_fields = ["username", "name"]
+
+
+@admin.register(models.Contribution)
+class ContributionAdmin(admin.ModelAdmin):
+    list_display = [
+        "contributor",
+        "summary",
+        "creation_date",
+        "import_date",
+        "type",
+        "external_id",
+    ]
+    search_fields = [
+        "contributor__username",
+        "contributor__name",
+        "summary",
+        "external_id",
+    ]
+
+    list_filter = ["contributor", "type"]
+
+    list_select_related = ["contributor"]
diff --git a/contributions/core/migrations/0001_initial.py b/contributions/core/migrations/0001_initial.py
new file mode 100644
index 0000000000000000000000000000000000000000..25745296aa2852b9368a01502cbaaa1a9f69899a
--- /dev/null
+++ b/contributions/core/migrations/0001_initial.py
@@ -0,0 +1,47 @@
+# Generated by Django 2.1.2 on 2018-10-06 15:48
+
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Contribution',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('summary', models.CharField(max_length=500)),
+                ('type', models.CharField(choices=[('dev', 'Development'), ('i18n', 'Translations'), ('network', 'Network'), ('donation', 'Donations'), ('communication', 'Communication'), ('other', 'Other')], max_length=50)),
+                ('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
+                ('import_date', models.DateTimeField(default=django.utils.timezone.now)),
+                ('is_visible', models.BooleanField(default=True)),
+                ('external_id', models.CharField(max_length=250, unique=True)),
+                ('url', models.URLField()),
+                ('metadata', django.contrib.postgres.fields.jsonb.JSONField()),
+            ],
+        ),
+        migrations.CreateModel(
+            name='Contributor',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('username', models.CharField(max_length=200, unique=True)),
+                ('name', models.CharField(max_length=200)),
+                ('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
+                ('is_visible', models.BooleanField(default=True)),
+                ('metadata', django.contrib.postgres.fields.jsonb.JSONField()),
+            ],
+        ),
+        migrations.AddField(
+            model_name='contribution',
+            name='contributor',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contributions', to='core.Contributor'),
+        ),
+    ]
diff --git a/contributions/core/migrations/__init__.py b/contributions/core/migrations/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/contributions/core/models.py b/contributions/core/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..a84207895880a31ba4b1afee58a119a3f4a5134c
--- /dev/null
+++ b/contributions/core/models.py
@@ -0,0 +1,44 @@
+from django.contrib.postgres.fields import JSONField
+from django.db import models
+from django.utils import timezone
+
+
+class Contributor(models.Model):
+    username = models.CharField(max_length=200, unique=True)
+    name = models.CharField(max_length=200)
+    creation_date = models.DateTimeField(default=timezone.now)
+    is_visible = models.BooleanField(default=True)
+    metadata = JSONField()
+
+    class Meta:
+        ordering = ("-creation_date",)
+
+    def __str__(self):
+        return self.username
+
+
+CONTRIBUTION_TYPES = [
+    ("dev", "Development"),
+    ("i18n", "Translations"),
+    ("network", "Network"),
+    ("donation", "Donations"),
+    ("communication", "Communication"),
+    ("other", "Other"),
+]
+
+
+class Contribution(models.Model):
+    contributor = models.ForeignKey(
+        Contributor, related_name="contributions", on_delete=models.CASCADE
+    )
+    summary = models.CharField(max_length=500)
+    type = models.CharField(max_length=50, choices=CONTRIBUTION_TYPES)
+    creation_date = models.DateTimeField(default=timezone.now)
+    import_date = models.DateTimeField(default=timezone.now)
+    is_visible = models.BooleanField(default=True)
+    external_id = models.CharField(max_length=250, unique=True)
+    url = models.URLField()
+    metadata = JSONField()
+
+    class Meta:
+        ordering = ("-creation_date",)
diff --git a/contributions/sources/gitlab.py b/contributions/sources/gitlab.py
new file mode 100644
index 0000000000000000000000000000000000000000..e92ad82405c8ac1928830a8cbf1738ab5663741e
--- /dev/null
+++ b/contributions/sources/gitlab.py
@@ -0,0 +1,59 @@
+import requests
+import pendulum
+
+from django.utils import timezone
+
+from contributions.core import models as core_models
+
+
+def retrieve_issue(gitlab_url, project_id, issue_id):
+    url = f"{gitlab_url}/api/v4/projects/{project_id}/issues/{issue_id}"
+    response = requests.get(url)
+    response.raise_for_status()
+    return response.json()
+
+
+def retrieve_issue_page(url):
+    response = requests.get(url)
+    response.raise_for_status()
+
+    links = response.links
+    next_page = None
+    if links and links.get("next"):
+        next_page = links["next"]["url"]
+    return response.json(), next_page
+
+
+def import_contributor_from_author(payload):
+    contributor = None
+    try:
+        contributor = core_models.Contributor.objects.get(username=payload["username"])
+    except core_models.Contributor.DoesNotExist:
+        return core_models.Contributor.objects.create(
+            username=payload["username"],
+            name=payload["name"],
+            metadata={"gitlab": payload},
+        )
+
+    contributor.metadata["gitlab"] = payload
+    contributor.save(update_fields=["metadata"])
+    return contributor
+
+
+def import_issue_as_contribution(payload):
+    contributor = import_contributor_from_author(payload["author"])
+    external_id = payload["_links"]["self"]
+    defaults = {
+        "summary": payload["title"],
+        "contributor": contributor,
+        "type": "dev",
+        "creation_date": pendulum.parse(payload["created_at"]),
+        "import_date": timezone.now(),
+        "is_visible": True,
+        "url": payload["web_url"],
+        "metadata": {"labels": payload["labels"]},
+    }
+
+    return core_models.Contribution.objects.update_or_create(
+        external_id=external_id, defaults=defaults
+    )[0]
diff --git a/local.yml b/local.yml
index b2bb4721e97f1ac201fa2241d12195854aa8f75b..48733ccb67d927f4784b1b0d773fb0d8c2bcf6b1 100644
--- a/local.yml
+++ b/local.yml
@@ -1,9 +1,5 @@
 version: '3'
 
-volumes:
-  local_postgres_data: {}
-  local_postgres_data_backups: {}
-
 services:
   django:
     build:
@@ -27,7 +23,6 @@ services:
       dockerfile: ./compose/production/postgres/Dockerfile
     image: contributions_production_postgres
     volumes:
-      - local_postgres_data:/var/lib/postgresql/data
-      - local_postgres_data_backups:/backups
+      - ./data/postgres:/var/lib/postgresql/data
     env_file:
       - ./.envs/.local/.postgres
diff --git a/pytest.ini b/pytest.ini
index 5b4369b89d53164b249c798da8cfc26c466c9e29..36617795ad5f63dd4e96db67193b678d1d36e1ec 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,2 +1,3 @@
 [pytest]
 DJANGO_SETTINGS_MODULE=config.settings.test
+testpaths = tests
diff --git a/requirements/base.txt b/requirements/base.txt
index 4d4b5e2a9904aba232c9997d0054fdc23f8086fe..43c17318ab2ad62e8fcad121e1a6013be7ce42de 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -12,3 +12,5 @@ django-redis==4.9.0  # https://github.com/niwinz/django-redis
 # Django REST Framework
 djangorestframework>=3.8,<3.9  # https://github.com/encode/django-rest-framework
 psycopg2==2.7.4 --no-binary psycopg2  # https://github.com/psycopg/psycopg2
+requests
+pendulum
diff --git a/requirements/local.txt b/requirements/local.txt
index 25ea1a028095432ba11d481efbb529f5ffb49959..0af83ecb1c01071c3d4642a4f4b125dd6fff9356 100644
--- a/requirements/local.txt
+++ b/requirements/local.txt
@@ -23,3 +23,5 @@ django-debug-toolbar==1.10.1  # https://github.com/jazzband/django-debug-toolbar
 django-extensions==2.1.3  # https://github.com/django-extensions/django-extensions
 django-coverage-plugin==1.6.0  # https://github.com/nedbat/django_coverage_plugin
 pytest-django==3.4.3  # https://github.com/pytest-dev/pytest-django
+requests-mock==1.5.2
+pytest-mock==1.10.0
diff --git a/tests/conftest.py b/tests/conftest.py
index f73ac88b4357881e746be856182e65325881d0fa..e8feb802baf99a3a04d995f6c2e33c99b0e78b49 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,13 +1,28 @@
 import pytest
 from django.conf import settings
 from django.test import RequestFactory
+from django.utils import timezone
 
-
-@pytest.fixture(autouse=True)
-def media_storage(settings, tmpdir):
-    settings.MEDIA_ROOT = tmpdir.strpath
+from . import factories as test_factories
 
 
 @pytest.fixture
 def request_factory() -> RequestFactory:
     return RequestFactory()
+
+
+@pytest.fixture
+def factories(db) -> test_factories:
+    return test_factories
+
+
+@pytest.fixture
+def nodb_factories() -> test_factories:
+    return test_factories
+
+
+@pytest.fixture
+def now(mocker):
+    n = timezone.now()
+    mocker.patch.object(timezone, 'now', return_value=n)
+    return n
diff --git a/tests/factories.py b/tests/factories.py
new file mode 100644
index 0000000000000000000000000000000000000000..d88e384c3732235dbdecbc3274396e6c4a34ba04
--- /dev/null
+++ b/tests/factories.py
@@ -0,0 +1,23 @@
+import factory
+
+from contributions.core import models as core_models
+
+
+class Contributor(factory.DjangoModelFactory):
+    name = factory.Faker('name')
+    metadata = factory.LazyAttribute(lambda o: {})
+
+    class Meta:
+        model = core_models.Contributor
+
+
+class Contribution(factory.DjangoModelFactory):
+    contributor = factory.SubFactory(Contributor)
+    summary = factory.Faker('paragraph')
+    type = factory.Iterator([t[0] for t in core_models.CONTRIBUTION_TYPES])
+    external_id = factory.Faker('uuid4')
+    url = factory.Faker('url')
+    metadata = factory.LazyAttribute(lambda o: {})
+
+    class Meta:
+        model = core_models.Contribution
diff --git a/tests/test_gitlab.py b/tests/test_gitlab.py
new file mode 100644
index 0000000000000000000000000000000000000000..f05ca253a5b75733dea22f9d919b0841c902f413
--- /dev/null
+++ b/tests/test_gitlab.py
@@ -0,0 +1,94 @@
+from contributions.sources import gitlab
+import pendulum
+
+
+def test_retrieve_issue(requests_mock):
+    gitlab_url = "https://gitlab.test"
+    project_id = 1
+    issue_id = 42
+
+    requests_mock.get(
+        f"https://gitlab.test/api/v4/projects/{project_id}/issues/{issue_id}",
+        json={"hello": "world"},
+    )
+    result = gitlab.retrieve_issue(gitlab_url, project_id, issue_id)
+
+    assert result == {"hello": "world"}
+
+
+def test_import_issue_as_contribution(now, db):
+    issue_payload = {
+        "id": 703,
+        "iid": 559,
+        "project_id": 17,
+        "title": "My awesome issue",
+        "created_at": "2018-10-05T15:23:17.257Z",
+        "labels": ["tag1", "tag2", "tag3"],
+        "author": {
+            "id": 130,
+            "name": "Alice",
+            "username": "Alice42",
+            "avatar_url": "https://secure.gravatar.com/avatar/1ed9cb8b4bdad28157104cd2f9468b3f?s=80\u0026d=identicon",
+            "web_url": "https://gitlab.test/Alice42",
+        },
+        "web_url": "https://gitlab.test/awesome/project/issues/559",
+        "_links": {"self": "https://gitlab.test/api/v4/projects/17/issues/559"},
+    }
+
+    contribution = gitlab.import_issue_as_contribution(issue_payload)
+
+    contributor = contribution.contributor
+
+    assert contributor.name == issue_payload["author"]["name"]
+    assert contributor.username == issue_payload["author"]["username"]
+    assert contributor.metadata == {"gitlab": issue_payload["author"]}
+
+    assert contribution.summary == issue_payload["title"]
+    assert contribution.type == "dev"
+    assert contribution.creation_date == pendulum.parse(issue_payload["created_at"])
+    assert contribution.import_date == now
+    assert contribution.is_visible is True
+    assert contribution.external_id == issue_payload["_links"]["self"]
+    assert contribution.url == issue_payload["web_url"]
+    assert contribution.metadata == {"labels": issue_payload["labels"]}
+
+
+def test_import_contributor_create(db):
+    payload = {"id": 130, "name": "Alice", "username": "Alice42"}
+
+    contributor = gitlab.import_contributor_from_author(payload)
+
+    assert contributor.name == payload["name"]
+    assert contributor.username == payload["username"]
+    assert contributor.metadata == {"gitlab": payload}
+
+
+def test_import_contributor_update_metadata(factories):
+    existing = factories.Contributor(username="Alice42", metadata={"hello": "world"})
+    payload = {"id": 130, "name": "Alice", "username": "Alice42"}
+
+    contributor = gitlab.import_contributor_from_author(payload)
+    contributor == existing
+
+    assert contributor.name == existing.name
+    assert contributor.username == existing.username
+    assert contributor.metadata == {"gitlab": payload, "hello": "world"}
+
+
+def test_retrieve_issue_page(requests_mock):
+    gitlab_url = "https://gitlab.test"
+    project_id = 1
+    url = f"{gitlab_url}/api/v4/projects/{project_id}/issues"
+
+    expected_next_page = (
+        f"https://gitlab.test/api/v4/projects/{project_id}/issues?page=2"
+    )
+    requests_mock.get(
+        f"https://gitlab.test/api/v4/projects/{project_id}/issues",
+        json=["hello", "world"],
+        headers={"Link": f'<{expected_next_page}>; rel="next"'},
+    )
+    result, next_page = gitlab.retrieve_issue_page(url)
+
+    assert result == ["hello", "world"]
+    assert expected_next_page == next_page
diff --git a/tests/test_models.py b/tests/test_models.py
index 6ca5ac1b59eac984285cf31edbf1748d9c35aecc..2c64ebb22b51d4f6ed552ff4c9d9bb01486bd1cf 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -1,20 +1,35 @@
 from django.utils import timezone
 
-from . import factories
+from contributions.core import models as core_models
 
 
-def test_contributor_factory():
-    c = factories.Contributor(
-        name='alice',
+def test_contributor_factory(factories):
+    contributor = factories.Contributor(
+        name="alice", is_visible=True, metadata={"hello": "world"}
+    )
+
+    assert contributor.name == "alice"
+    assert contributor.creation_date < timezone.now()
+    assert contributor.is_visible is True
+    assert contributor.metadata == {"hello": "world"}
+
+
+def test_contribution_factory(factories):
+    contribution = factories.Contribution(
+        external_id="weblate:front",
+        metadata={"locale": "en"},
         is_visible=True,
-        links={
-            'hello': 'world'
-        }
+        url="https://weblate.test",
+        summary="Hello",
+        type="i18n:translation",
     )
 
-    assert c.name == 'alice'
-    assert c.creation_date < timezone.now()
-    assert c.is_visible is True
-    assert c.links == {
-        'hello': 'world'
-    }
+    assert isinstance(contribution.contributor, core_models.Contributor)
+    assert contribution.creation_date < timezone.now()
+    assert contribution.import_date < timezone.now()
+    assert contribution.is_visible is True
+    assert contribution.metadata == {"locale": "en"}
+    assert contribution.summary == "Hello"
+    assert contribution.type == "i18n:translation"
+    assert contribution.external_id == "weblate:front"
+    assert contribution.url == "https://weblate.test"