From a21998487ce220e67b6b065479bc0bd4acd46382 Mon Sep 17 00:00:00 2001
From: Jan-Hendrik Willms <tleilax+studip@gmail.com>
Date: Sun, 7 Apr 2024 00:41:48 +0000
Subject: [PATCH] review apps, fixes #53

---
 TracToGitlabPlugin.php                        |  18 +-
 assets/apps/dashboard.js                      | 106 +++++++++++
 assets/script.js                              |  95 +---------
 assets/style.scss                             |  15 +-
 bootstrap.php                                 |   5 +
 composer.json                                 |   2 -
 composer.lock                                 | 148 +---------------
 controllers/admin.php                         |  43 +++++
 controllers/dashboard.php                     |  60 +++----
 controllers/issues.php                        | 142 ---------------
 controllers/labels.php                        |  14 +-
 controllers/merge.php                         |  38 ++--
 controllers/mergerequests.php                 |  33 ++--
 controllers/releases.php                      | 159 ++++++++---------
 controllers/users.php                         |   8 +-
 controllers/webhooks.php                      |  80 +++++++++
 lib/BytistConnector.php                       |  35 ++++
 lib/Controller.php                            |  18 --
 lib/EventController.php                       |   6 +-
 lib/EventHandler.php                          |   9 +
 .../AllAutomaticJobsHaveSucceeded.php         |  53 ++++++
 lib/EventHandlers/BranchDeleted.php           |  37 ++++
 lib/EventHandlers/BuildImageJobSucceeded.php  |  42 +++++
 lib/EventHandlers/IssueCreated.php            | 165 ++++++++++++++++++
 lib/GitlabController.php                      |  41 ++---
 lib/GitlabIssue.php                           |  59 ++++---
 lib/Hooks/Issue.php                           |  17 ++
 lib/Hooks/Pipeline.php                        |  17 ++
 lib/Hooks/Push.php                            |  17 ++
 lib/Models/GitlabReviewApp.php                |  12 ++
 lib/Plugin.php                                |  77 ++++----
 lib/Traits/Cached.php                         |  65 +++++++
 lib/Traits/GetBuildImageJob.php               |  16 ++
 migrations/6_setup_bytist_connection.php      |  19 ++
 .../7_add_webhook_secret_configuration.php    |  20 +++
 migrations/8_setup_database.php               |  20 +++
 .../9_add_systemhook_secret_configuration.php |  19 ++
 views/admin/index.php                         |  55 ++++++
 views/dashboard/index.php                     |  46 +++--
 views/merge/sidebar.php                       |   1 -
 views/mergerequests/sidebar.php               |   1 -
 views/sidebar.php                             |   6 +
 42 files changed, 1147 insertions(+), 692 deletions(-)
 create mode 100644 assets/apps/dashboard.js
 create mode 100644 controllers/admin.php
 delete mode 100644 controllers/issues.php
 create mode 100644 controllers/webhooks.php
 create mode 100644 lib/BytistConnector.php
 create mode 100644 lib/EventHandler.php
 create mode 100644 lib/EventHandlers/AllAutomaticJobsHaveSucceeded.php
 create mode 100644 lib/EventHandlers/BranchDeleted.php
 create mode 100644 lib/EventHandlers/BuildImageJobSucceeded.php
 create mode 100644 lib/EventHandlers/IssueCreated.php
 create mode 100644 lib/Hooks/Issue.php
 create mode 100644 lib/Hooks/Pipeline.php
 create mode 100644 lib/Hooks/Push.php
 create mode 100644 lib/Models/GitlabReviewApp.php
 create mode 100644 lib/Traits/Cached.php
 create mode 100644 lib/Traits/GetBuildImageJob.php
 create mode 100644 migrations/6_setup_bytist_connection.php
 create mode 100644 migrations/7_add_webhook_secret_configuration.php
 create mode 100644 migrations/8_setup_database.php
 create mode 100644 migrations/9_add_systemhook_secret_configuration.php
 create mode 100644 views/admin/index.php
 delete mode 100644 views/merge/sidebar.php
 delete mode 100644 views/mergerequests/sidebar.php
 create mode 100644 views/sidebar.php

diff --git a/TracToGitlabPlugin.php b/TracToGitlabPlugin.php
index b605157..3bdb8f6 100644
--- a/TracToGitlabPlugin.php
+++ b/TracToGitlabPlugin.php
@@ -14,11 +14,15 @@ final class TracToGitlabPlugin extends TracToGitlab\Plugin implements SystemPlug
             'TracToGitlabPlugin::markupGitlabLinks'
         );
 
-        if (!is_object($GLOBALS['user']) || $GLOBALS['user']->id === 'nobody') {
+        if (!User::findCurrent()) {
             return;
         }
 
-        $this->buildNavigation();
+        $this->buildDashboardNavigation();
+
+        if (User::findCurrent()->perms === 'root') {
+            $this->buildAdminNavigation();
+        }
     }
 
     public function getPluginName()
@@ -34,7 +38,7 @@ final class TracToGitlabPlugin extends TracToGitlab\Plugin implements SystemPlug
         parent::perform($unconsumed);
     }
 
-    private function buildNavigation()
+    private function buildDashboardNavigation()
     {
         $navigation = new Navigation(_('Dashboard'), PluginEngine::getURL($this, [], 'dashboard'));
         $navigation->setImage(Icon::create($this->getPluginURL() . '/assets/cardiogram.svg', Icon::ROLE_NAVIGATION));
@@ -67,6 +71,14 @@ final class TracToGitlabPlugin extends TracToGitlab\Plugin implements SystemPlug
         Navigation::addItem('/gitlab-dashboard', $navigation);
     }
 
+    private function buildAdminNavigation(): void
+    {
+        Navigation::addItem('/admin/trac2gitlab', new Navigation(
+            _('GitLab-Verbindung konfigurieren'),
+            PluginEngine::getURL($this, [], 'admin/index')
+        ));
+    }
+
     public static function markupGitlabLinks($markup, $matches, $contents)
     {
         if ($matches[1][0] === '#') {
diff --git a/assets/apps/dashboard.js b/assets/apps/dashboard.js
new file mode 100644
index 0000000..3bc65d9
--- /dev/null
+++ b/assets/apps/dashboard.js
@@ -0,0 +1,106 @@
+export default {
+    data: () => ({
+        needle: '',
+        filters: {},
+    }),
+    created() {
+        if (this.storedFilters !== null) {
+            Object.entries(this.storedFilters).forEach(([filter, value]) => {
+                this.$set(this.filters, filter, value);
+            });
+        }
+
+        Object.values(this.qmLabels).concat(['status', 'mr_status']).forEach(abbr => {
+            if (this.filters[abbr] === undefined) {
+                this.$set(this.filters, abbr, null);
+            }
+        });
+
+        this.$watch('filters', this.storeFilters, {deep: true});
+    },
+    methods: {
+        getStateForIssueAndQmLabel(issue, qm) {
+            return issue.qm_states[qm];
+        },
+        valueMatchesNeedle(what) {
+            if (this.needle.length === 0) {
+                return false;
+            }
+
+            return what.toLowerCase().includes(this.needle.toLowerCase());
+        },
+        storeFilters(filters) {
+            const data = new URLSearchParams();
+
+            for (const [label, value] of Object.entries(filters)) {
+                if (value !== null) {
+                    data.append(`filters[${label}]`, value);
+                }
+            }
+
+            fetch(this.storeFiltersUrl, {
+                method: 'POST',
+                body: data,
+                headers: {
+                    'X-Requested-With': 'XMLHttpRequest'
+                }
+            });
+        }
+    },
+    computed: {
+        colspan() {
+            return 8 + Object.values(this.qmLabels).length;
+        },
+        filteredIssues() {
+            let filtered = this.issues.filter(issue => {
+                for (const abbr of Object.values(this.qmLabels)) {
+                    if (this.filters[abbr] === null) {
+                        continue;
+                    }
+                    if (issue.qm_states[abbr] !== this.filters[abbr]) {
+                        return false;
+                    }
+                }
+
+                if (this.filters.status) {
+                    if (this.filters.status === 'open' && issue.closed) {
+                        return false;
+                    }
+                    if (this.filters.status === 'closed' && !issue.closed) {
+                        return false;
+                    }
+                }
+
+                if (this.filters.mr_status) {
+                    if (this.filters.mr_status === 'none' && issue.merge_requests) {
+                        return false;
+                    }
+                    if (this.filters.mr_status === 'merged' && !issue.merged) {
+                        return false;
+                    }
+                    if (
+                        this.filters.mr_status === 'pending'
+                        && (!issue.merge_requests || issue.merged)
+                    ) {
+                        return false;
+                    }
+                }
+
+                return true;
+            });
+
+            if (this.needle.length > 0) {
+                filtered = filtered.filter(issue => {
+                    const ciNeedle = this.needle.toLowerCase();
+                    return issue.iid.toString().includes(this.needle)
+                        || issue.title.toLowerCase().includes(ciNeedle)
+                        || (issue.assignee ?? '').toLowerCase().includes(ciNeedle)
+                        || (issue.author ?? '').toLowerCase().includes(ciNeedle)
+                        || issue.reviewers.some(reviewer => reviewer.toLowerCase().includes(ciNeedle));
+                });
+            }
+
+            return filtered;
+        }
+    }
+}
diff --git a/assets/script.js b/assets/script.js
index 32b3bb6..c09f62e 100644
--- a/assets/script.js
+++ b/assets/script.js
@@ -1,7 +1,8 @@
-(function ($, STUDIP) {
+(function (STUDIP) {
     'use strict';
 
     const lookup = {
+        '#dashboard-app': () => import('./apps/dashboard.js'),
         '#labels-list-app': () => import('./apps/labels.js'),
     };
 
@@ -31,94 +32,4 @@
         });
     });
 
-    $(document).ready(function () {
-        if (document.getElementById('dashboard') !== null) {
-            const dashboard = document.getElementById('dashboard');
-            const issues = JSON.parse(dashboard.dataset.issues);
-            const qmLabels = JSON.parse(dashboard.dataset.qmLabels);
-            const filters = JSON.parse(dashboard.dataset.filters) || {};
-            const filterStoreUrl = dashboard.dataset.filterStoreUrl;
-
-            Object.values(qmLabels).forEach(abbr => {
-                if (filters[abbr] === undefined) {
-                    filters[abbr] = null;
-                }
-            });
-
-            STUDIP.loadChunk('vue').then(({createApp}) => {
-                createApp({
-                    data () {
-                        return {
-                            needle: '',
-                            issues,
-                            qmLabels,
-                            filters
-                        };
-                    },
-                    methods: {
-                        getStateForIssueAndQmLabel(issue, qm) {
-                            return issue.qm_states[qm];
-                        },
-                        valueMatchesNeedle(what) {
-                            if (this.needle.length === 0) {
-                                return false;
-                            }
-
-                            return what.toLowerCase().includes(this.needle.toLowerCase());
-                        }
-                    },
-                    computed: {
-                        colspan() {
-                            return 8 + Object.values(qmLabels).length;
-                        },
-                        filteredIssues() {
-                            let filtered = this.issues.filter(issue => {
-                                for (const [key, value] of Object.entries(this.filters)) {
-                                    if (value === null) {
-                                        continue;
-                                    }
-                                    if (issue.qm_states[key] !== value) {
-                                        return false;
-                                    }
-                                }
-                                return true;
-                            });
-
-                            if (this.needle.length > 0) {
-                                filtered = filtered.filter(issue => {
-                                    const ciNeedle = this.needle.toLowerCase();
-                                    return issue.iid.toString().includes(this.needle)
-                                        || issue.title.toLowerCase().includes(ciNeedle)
-                                        || (issue.assignee ?? '').toLowerCase().includes(ciNeedle)
-                                        || (issue.author ?? '').toLowerCase().includes(ciNeedle)
-                                        || issue.reviewers.some(reviewer => reviewer.toLowerCase().includes(ciNeedle));
-                                });
-                            }
-
-                            return filtered;
-                        }
-                    },
-                    watch: {
-                        filters: {
-                            handler(current) {
-                                const data = new URLSearchParams();
-
-                                for (const [label, value] of Object.entries(current)) {
-                                    if (value !== null) {
-                                        data.append(`filters[${label}]`, value);
-                                    }
-                                }
-
-                                fetch(filterStoreUrl, {
-                                    method: 'POST',
-                                    body: data
-                                });
-                            },
-                            deep: true
-                        }
-                    }
-                }).$mount('#dashboard');
-            });
-        }
-    });
-}(jQuery, STUDIP));
+}(STUDIP));
diff --git a/assets/style.scss b/assets/style.scss
index 42a679f..1580855 100644
--- a/assets/style.scss
+++ b/assets/style.scss
@@ -1,8 +1,19 @@
 td.filter-match {
     background-color: var(--yellow-20);
 }
-#dashboard table.default td {
-    vertical-align: top;
+#dashboard-app {
+    table.default td {
+        vertical-align: top;
+    }
+
+    .review-app-list {
+        &::before {
+            content: '[';
+        }
+        &::after {
+            content: ']';
+        }
+    }
 }
 
 #labels-list-app {
diff --git a/bootstrap.php b/bootstrap.php
index 99fbeae..8ec86c7 100644
--- a/bootstrap.php
+++ b/bootstrap.php
@@ -1,2 +1,7 @@
 <?php
+use Psr\Http\Client\ClientInterface;
+use Studip\DIContainer;
+use TracToGitlab\BytistConnector;
+use TracToGitlab\TracLookup;
+
 StudipAutoloader::addAutoloadPath(__DIR__ . '/lib', 'TracToGitlab');
diff --git a/composer.json b/composer.json
index 9fbc2b2..bba2d97 100644
--- a/composer.json
+++ b/composer.json
@@ -7,13 +7,11 @@
         }
     ],
     "require": {
-        "fguillot/json-rpc": "^1.2",
         "m4tthumphrey/php-gitlab-api": "^11.12.0",
         "guzzlehttp/guzzle": "^7.2",
         "http-interop/http-factory-guzzle": "^1.0",
         "erusev/parsedown": "^1.7",
         "ext-json": "*",
-        "pimple/pimple": "^3.4",
         "ext-curl": "*"
     },
     "config": {
diff --git a/composer.lock b/composer.lock
index a18a032..78f1408 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "364dc3e4d32decc645b3e2914dae455b",
+    "content-hash": "d7ac7fbb441d73b0404063ba0c3f4e9c",
     "packages": [
         {
             "name": "clue/stream-filter",
@@ -122,49 +122,6 @@
             },
             "time": "2019-12-30T22:54:17+00:00"
         },
-        {
-            "name": "fguillot/json-rpc",
-            "version": "v1.2.8",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/matasarei/JsonRPC.git",
-                "reference": "f1eef90bf0bb3f7779c9c8113311811ef449ece8"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/matasarei/JsonRPC/zipball/f1eef90bf0bb3f7779c9c8113311811ef449ece8",
-                "reference": "f1eef90bf0bb3f7779c9c8113311811ef449ece8",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.4"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "4.8.*"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-0": {
-                    "JsonRPC": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Frédéric Guillot"
-                }
-            ],
-            "description": "Simple Json-RPC client/server library that just works",
-            "homepage": "https://github.com/matasarei/JsonRPC",
-            "support": {
-                "issues": "https://github.com/matasarei/JsonRPC/issues",
-                "source": "https://github.com/matasarei/JsonRPC/tree/v1.2.8"
-            },
-            "time": "2019-03-23T16:13:00+00:00"
-        },
         {
             "name": "guzzlehttp/guzzle",
             "version": "7.3.0",
@@ -1037,59 +994,6 @@
             },
             "time": "2023-11-08T12:57:08+00:00"
         },
-        {
-            "name": "pimple/pimple",
-            "version": "v3.4.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/silexphp/Pimple.git",
-                "reference": "86406047271859ffc13424a048541f4531f53601"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/silexphp/Pimple/zipball/86406047271859ffc13424a048541f4531f53601",
-                "reference": "86406047271859ffc13424a048541f4531f53601",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.2.5",
-                "psr/container": "^1.1"
-            },
-            "require-dev": {
-                "symfony/phpunit-bridge": "^5.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "3.4.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-0": {
-                    "Pimple": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Fabien Potencier",
-                    "email": "fabien@symfony.com"
-                }
-            ],
-            "description": "Pimple, a simple Dependency Injection Container",
-            "homepage": "https://pimple.symfony.com",
-            "keywords": [
-                "container",
-                "dependency injection"
-            ],
-            "support": {
-                "source": "https://github.com/silexphp/Pimple/tree/v3.4.0"
-            },
-            "time": "2021-03-06T08:28:00+00:00"
-        },
         {
             "name": "psr/cache",
             "version": "1.0.1",
@@ -1139,54 +1043,6 @@
             },
             "time": "2016-08-06T20:24:11+00:00"
         },
-        {
-            "name": "psr/container",
-            "version": "1.1.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/php-fig/container.git",
-                "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/container/zipball/8622567409010282b7aeebe4bb841fe98b58dcaf",
-                "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.2.0"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Psr\\Container\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "PHP-FIG",
-                    "homepage": "https://www.php-fig.org/"
-                }
-            ],
-            "description": "Common Container Interface (PHP FIG PSR-11)",
-            "homepage": "https://github.com/php-fig/container",
-            "keywords": [
-                "PSR-11",
-                "container",
-                "container-interface",
-                "container-interop",
-                "psr"
-            ],
-            "support": {
-                "issues": "https://github.com/php-fig/container/issues",
-                "source": "https://github.com/php-fig/container/tree/1.1.1"
-            },
-            "time": "2021-03-05T17:36:06+00:00"
-        },
         {
             "name": "psr/http-client",
             "version": "1.0.3",
@@ -1701,5 +1557,5 @@
         "ext-curl": "*"
     },
     "platform-dev": [],
-    "plugin-api-version": "2.3.0"
+    "plugin-api-version": "2.6.0"
 }
diff --git a/controllers/admin.php b/controllers/admin.php
new file mode 100644
index 0000000..68c994d
--- /dev/null
+++ b/controllers/admin.php
@@ -0,0 +1,43 @@
+<?php
+final class AdminController extends \TracToGitlab\Controller
+{
+    public function before_filter(&$action, &$args)
+    {
+        parent::before_filter($action, $args);
+
+        if (User::findCurrent()->perms !== 'root') {
+            throw new AccessDeniedException();
+        }
+
+        Navigation::activateItem('/admin/trac2gitlab');
+        PageLayout::setTitle(_('GitLab-Verbindung konfigurieren'));
+    }
+
+    public function index_action()
+    {
+        $this->gitlab_url = Config::get()->TRAC2GITLAB_GITLAB_URL;
+        $this->gitlab_token = Config::get()->TRAC2GITLAB_GITLAB_TOKEN;
+        $this->gitlab_project_id = Config::get()->TRAC2GITLAB_GITLAB_PROJECT_ID;
+        $this->bytist_token = Config::get()->TRAC2GITLAB_BYTIST_TOKEN;
+        $this->webhook_secret = Config::get()->TRAC2GITLAB_GITLAB_WEBHOOK_SECRET;
+        $this->systemhook_secret = Config::get()->TRAC2GITLAB_GITLAB_SYSTEMHOOK_SECRET;
+    }
+
+    public function store_action()
+    {
+        if (!Request::isPost()) {
+            throw new MethodNotAllowedException();
+        }
+
+        Config::get()->store('TRAC2GITLAB_GITLAB_URL', Request::get('gitlab-url'));
+        Config::get()->store('TRAC2GITLAB_GITLAB_TOKEN', Request::get('gitlab-token'));
+        Config::get()->store('TRAC2GITLAB_GITLAB_PROJECT_ID', Request::int('gitlab-project-id'));
+        Config::get()->store('TRAC2GITLAB_BYTIST_TOKEN', Request::get('bytist-token'));
+        Config::get()->store('TRAC2GITLAB_GITLAB_WEBHOOK_SECRET', Request::get('webhook-secret'));
+        Config::get()->store('TRAC2GITLAB_GITLAB_SYSTEMHOOK_SECRET', Request::get('systemhook-secret'));
+
+        PageLayout::postSuccess(_('Einstellungen wurden gespeichert'));
+        $this->redirect($this->indexURL());
+    }
+
+}
diff --git a/controllers/dashboard.php b/controllers/dashboard.php
index e506596..c75a1ea 100644
--- a/controllers/dashboard.php
+++ b/controllers/dashboard.php
@@ -1,9 +1,11 @@
 <?php
 
+use TracToGitlab\GitlabIssue;
+
 /**
  * @property User|null $user
  */
-final class DashboardController extends TracToGitlab\Controller
+final class DashboardController extends TracToGitlab\GitlabController
 {
     public function before_filter(&$action, &$args)
     {
@@ -65,9 +67,15 @@ final class DashboardController extends TracToGitlab\Controller
 
     public function index_action()
     {
-        $this->issues  = $this->getIssues();
+        $this->issues = $this->cached('issues', fn() => $this->getIssues());
         $this->mapping = $this->getQMLabelMapping();
         $this->filters = $this->getSelected('filters');
+
+        Sidebar::get()->addWidget(new TemplateWidget(
+            _('Stand der Daten'),
+            $this->get_template_factory()->open('sidebar'),
+            ['time' => $this->getCachedDate('issues')]
+        ));
     }
 
     public function select_action(string $what, string $value = null)
@@ -118,11 +126,11 @@ final class DashboardController extends TracToGitlab\Controller
         foreach (explode(',', $this->getSelected('types')) as $type) {
             $issues = array_merge(
                 $issues,
-                $this->gitlabPager->fetchAll(
+                $this->result_pager->fetchAll(
                     $this->gitlab->issues(),
                     'all',
                     [
-                        $this->gitlabProjectId,
+                        $this->gitlab_project_id,
                         [
                             'sort'      => 'asc',
                             'scope'     => 'all',
@@ -137,23 +145,27 @@ final class DashboardController extends TracToGitlab\Controller
             return $a['id'] - $b['id'];
         });
 
-        return array_map(function ($issue) {
-            $mrs = $this->gitlab->issues()->relatedMergeRequests($this->gitlabProjectId, $issue['iid']);
-            return new TracToGitlab\GitlabIssue($issue, $mrs);
+        return array_map(function (array $issue): GitlabIssue {
+            $mrs = $this->gitlab->issues()->closedByMergeRequests(
+                $this->gitlab_project_id,
+                $issue['iid']
+            );
+            return new GitlabIssue($issue, $mrs);
         }, $issues);
     }
 
     private function getMilestones()
     {
-        $milestones = $this->gitlabPager->fetchAll(
-            $this->gitlab->milestones(),
-            'all',
-            [$this->gitlabProjectId]
-        );
-        $milestones = array_filter($milestones, function ($milestone) {
-            return preg_match('/^Stud\.IP \d+\.\d+$/', $milestone['title']);
+        return $this->cached('milestones', function () {
+            $milestones = $this->result_pager->fetchAll(
+                $this->gitlab->milestones(),
+                'all',
+                [Config::get()->TRAC2GITLAB_GITLAB_PROJECT_ID]
+            );
+            return array_filter($milestones, function ($milestone) {
+                return preg_match('/^Stud\.IP \d+\.\d+$/', $milestone['title']);
+            });
         });
-        return $milestones;
     }
 
     private function getMilestonesAsSelection()
@@ -165,24 +177,6 @@ final class DashboardController extends TracToGitlab\Controller
         return $result;
     }
 
-    private function getQMLabels()
-    {
-        $labels = $this->gitlabPager->fetchAll(
-            $this->gitlab->projects(),
-            'labels',
-            [$this->gitlabProjectId]
-        );
-        $labels = array_filter($labels, function ($label) {
-            return strpos($label['name'], 'QM::') === 0;
-        });
-        $labels = array_map(function ($label) {
-            return substr($label['name'], 4, -3);
-        }, $labels);
-        $labels = array_unique($labels);
-        $labels = array_values($labels);
-        return $labels;
-    }
-
     private function getQMLabelMapping()
     {
         return TracToGitlab\GitlabIssue::QM_LABEL_MAPPING;
diff --git a/controllers/issues.php b/controllers/issues.php
deleted file mode 100644
index 7774098..0000000
--- a/controllers/issues.php
+++ /dev/null
@@ -1,142 +0,0 @@
-<?php
-final class IssuesController extends TracToGitlab\EventController
-{
-    const SECRET_ISSUE_OPEN = 'N]<d5V/6tn/sYNMy';
-
-    const LABEL_MAPPING = [
-        'TIC'  => [
-            'course_id' => '1927f2b86d6b185aa6c6697810ad42f1',
-            'subject'   => 'TIC #%1$u: %2$s',
-            'body'      => "%1\$s\n\n----\nZum [gitlab-Issue #%2\$u](%3\$s)",
-        ],
-        'StEP' => [
-            'course_id' => '1927f2b86d6b185aa6c6697810ad42f1',
-            'subject'   => 'StEP00%1$u: %2$s',
-            'body'      => "%1\$s\n\n----\nZum [gitlab-Issue #%2\$u](%3\$s)",
-        ],
-    ];
-
-    public function create_action()
-    {
-        if (!Request::isPost()) {
-            throw new MethodNotAllowedException();
-        }
-
-        if (!$this->verifySecret(self::SECRET_ISSUE_OPEN)) {
-            throw new AccessDeniedException();
-        }
-
-        if ($this->getFromPayload('object_attributes', 'action') === 'open') {
-            $username = $this->getFromPayload('user', 'username');
-            $email = $this->getFromPayload('user', 'email');
-
-            $user = User::findOneByUsername($username)
-                 ?? User::findOneByEmail($email)
-                 ?? User::findOneByUsername('gitlab-bot');
-
-            if ($user) {
-                $labels = $this->getFromPayload('labels');
-                foreach (self::LABEL_MAPPING as $label => $definition) {
-                    if (!$this->hasLabel($labels, $label)) {
-                        continue;
-                    }
-                    $title = sprintf(
-                        $definition['subject'],
-                        $this->getFromPayload('object_attributes', 'iid'),
-                        $this->getFromPayload('object_attributes', 'title')
-                    );
-                    $body = Studip\Markup::markAsHtml(
-                        $this->parsedown->text(
-                            sprintf(
-                                $definition['body'],
-                                $this->getFromPayload('object_attributes', 'description'),
-                                $this->getFromPayload('object_attributes', 'iid'),
-                                $this->getFromPayload('object_attributes', 'url')
-                            )
-                        )
-                    );
-
-                    $url = $this->createForumEntry(
-                        $label,
-                        $user,
-                        $title,
-                        $body,
-                        $definition['course_id']
-                    );
-
-                    if ($url) {
-                        $this->gitlab->issues()->addNote(
-                            $this->getFromPayload('project', 'id'),
-                            $this->getFromPayload('object_attributes', 'iid'),
-                            "[Zum Forenbeitrag auf dem Entwicklungsserver]({$url})"
-                        );
-                    }
-
-                }
-            }
-        }
-        $this->render_nothing();
-    }
-
-    private function hasLabel(array $labels, string $needle): bool
-    {
-        foreach ($labels as $label) {
-            if ($label['title'] === $needle) {
-                return true;
-            }
-        }
-
-        return false;
-    }
-
-    /**
-     * @return ?string Absolute URL to forum posting
-     */
-    private function createForumEntry(string $type, \User $user, string $subject, string $body, string $course_id)
-    {
-        if (!PluginEngine::getPlugin('CoreForum')) {
-            return null;
-        }
-
-        $query = "SELECT category_id
-                  FROM forum_categories
-                  WHERE seminar_id = ?
-                    AND entry_name LIKE CONCAT('Aktive ', ?, '%')";
-        $category_id = DBManager::get()->fetchColumn($query, [$course_id, $type]);
-
-        $topic_id = md5(uniqid('CoreForum'));
-        ForumEntry::insert([
-            'topic_id'    => $topic_id,
-            'seminar_id'  => $course_id,
-            'user_id'     => $user->id,
-            'name'        => $subject,
-            'content'     => $subject,
-            'author'      => $user->getFullName(),
-            'author_host' => $_SERVER['REMOTE_ADDR']
-        ], $course_id);
-
-        if ($category_id) {
-            ForumCat::addArea($category_id, $topic_id);
-        }
-
-        $topic_id2 = md5(uniqid('CoreForum'));
-        ForumEntry::insert([
-            'topic_id'    => $topic_id2,
-            'seminar_id'  => $course_id,
-            'user_id'     => $user->id,
-            'name'        => $subject,
-            'content'     => $body,
-            'author'      => $user->getFullName(),
-            'author_host' => $_SERVER['REMOTE_ADDR']
-        ], $topic_id);
-
-        $old_base = \URLHelper::setBaseURL($GLOBALS['ABSOLUTE_URI_STUDIP']);
-        $url = URLHelper::getURL(
-            "dispatch.php/course/forum/index/index/{$topic_id2}#{$topic_id2}",
-            ['cid' => $course_id]
-        );
-        \URLHelper::setBaseURL($old_base);
-
-        return $url;
-    }
-}
diff --git a/controllers/labels.php b/controllers/labels.php
index 36827e3..8a9a52c 100644
--- a/controllers/labels.php
+++ b/controllers/labels.php
@@ -11,27 +11,21 @@ final class LabelsController extends \TracToGitlab\GitlabController
 
     public function index_action()
     {
-        $data = $this->readFromCache('labels', true);
-        if ($data === false) {
-            $this->labels = $this->fetchLabels();
-            $this->writeToCache('labels', $this->labels);
-        } else {
-            $this->labels = $data['data'];
-        }
+        $this->labels = $this->cached('labels', fn() => $this->fetchLabels());
     }
 
     private function fetchLabels(): array
     {
-        $labels = $this->gitlabPager->fetchAll(
+        $labels = $this->result_pager->fetchAll(
             $this->gitlab->projects(),
             'labels',
             [
-                $this->gitlabProjectId,
+                $this->gitlab_project_id,
                 ['with_counts' => true]
             ]
         );
 
-        usort($labels, function ($a, $b) {
+        usort($labels, function (array $a, array $b) {
             return strnatcasecmp($a['name'], $b['name']);
         });
 
diff --git a/controllers/merge.php b/controllers/merge.php
index 9ad0002..d2b6046 100644
--- a/controllers/merge.php
+++ b/controllers/merge.php
@@ -11,35 +11,22 @@ final class MergeController extends TracToGitlab\GitlabController
 
     public function index_action()
     {
-        $data = $this->readFromCache('issues-to-merge', true);
-        if ($data === false) {
-            $this->issues = $this->fetchIssues();
-            $this->writeToCache('issues-to-merge', $this->issues);
-        } else {
-            $this->issues = $data['data'];
+        $this->issues = $this->cached('issues-to-merge', fn() => $this->fetchIssues());
 
-            Sidebar::get()->addWidget(new TemplateWidget(
-                'Aus dem Cache',
-                $this->get_template_factory()->open(
-                    $this->get_default_template('sidebar')
-                ),
-                ['time' => time()]
-            ));
-        }
-    }
-
-    public function diff_action($mr_iid)
-    {
-//        $versions = $this->gitlab->mergeRequests()->
+        Sidebar::get()->addWidget(new TemplateWidget(
+            _('Stand der Daten'),
+            $this->get_template_factory()->open('sidebar'),
+            ['time' => $this->getCachedDate('issues-to-merge')]
+        ));
     }
 
     private function fetchIssues(): array
     {
-        $issues = $this->gitlabPager->fetchAll(
+        $issues = $this->result_pager->fetchAll(
             $this->gitlab->issues(),
             'all',
             [
-                $this->gitlabProjectId,
+                $this->gitlab_project_id,
                 [
                     'sort'      => 'asc',
                     'scope'     => 'all',
@@ -61,10 +48,9 @@ final class MergeController extends TracToGitlab\GitlabController
                 }
             }
 
-            $has_version = array_reduce($issue['labels'], function ($has_version, $label) {
-                return $has_version || strpos($label, 'Version::') === 0;
+            return array_reduce($issue['labels'], function ($has_version, $label) {
+                return $has_version || str_starts_with($label, 'Version::');
             }, false);
-            return $has_version;
         });
 
         $issues = array_map(function ($issue) {
@@ -83,7 +69,7 @@ final class MergeController extends TracToGitlab\GitlabController
     private function fetchRelatedMergeRequest(int $issue_id)
     {
         $mrs = $this->gitlab->issues()->closedByMergeRequests(
-            $this->gitlabProjectId,
+            $this->gitlab_project_id,
             $issue_id
         );
         foreach ($mrs as $mr) {
@@ -98,7 +84,7 @@ final class MergeController extends TracToGitlab\GitlabController
     {
         $version = '';
         foreach ($labels as $label) {
-            if (strpos($label, 'Version::') === 0) {
+            if (str_starts_with($label, 'Version::')) {
                 $v = substr($label, 9);
                 if (!$version || $v < $version) {
                     $version = $v;
diff --git a/controllers/mergerequests.php b/controllers/mergerequests.php
index 61de293..66985de 100644
--- a/controllers/mergerequests.php
+++ b/controllers/mergerequests.php
@@ -11,35 +11,22 @@ final class MergerequestsController extends TracToGitlab\GitlabController
 
     public function index_action()
     {
-        $data = $this->readFromCache('mergerequests', true);
-        if ($data === false) {
-            $this->mrs = $this->fetchMergeRequests();
-            $this->writeToCache('mergerequests', $this->mrs);
-        } else {
-            $this->mrs = $data['data'];
+        $this->mrs = $this->cached('mergerequests', fn() => $this->fetchMergeRequests());
 
-            Sidebar::get()->addWidget(new TemplateWidget(
-                'Aus dem Cache',
-                $this->get_template_factory()->open(
-                    $this->get_default_template('sidebar')
-                ),
-                ['time' => $data['time']]
-            ));
-        }
-    }
-
-    public function diff_action($mr_iid)
-    {
-//        $versions = $this->gitlab->mergeRequests()->
+        Sidebar::get()->addWidget(new TemplateWidget(
+            _('Aus dem Cache'),
+            $this->get_template_factory()->open('sidebar'),
+            ['time' => $this->getCachedDate('mergerequests')]
+        ));
     }
 
     private function fetchMergeRequests(): array
     {
-        $mrs = $this->gitlabPager->fetchAll(
+        $mrs = $this->result_pager->fetchAll(
             $this->gitlab->mergeRequests(),
             'all',
             [
-                $this->gitlabProjectId,
+                $this->gitlab_project_id,
                 [
                     'sort'  => 'asc',
                     'scope' => 'all',
@@ -58,7 +45,7 @@ final class MergerequestsController extends TracToGitlab\GitlabController
 
         $mrs = array_map(function ($mr) {
             $mr['approvals'] = $this->gitlab->mergeRequests()->approvals(
-                $this->gitlabProjectId,
+                $this->gitlab_project_id,
                 $mr['iid']
             );
             return $mr;
@@ -74,7 +61,7 @@ final class MergerequestsController extends TracToGitlab\GitlabController
     private function fetchRelatedMergeRequest(int $issue_id)
     {
         $mrs = $this->gitlab->issues()->closedByMergeRequests(
-            $this->gitlabProjectId,
+            $this->gitlab_project_id,
             $issue_id
         );
         foreach ($mrs as $mr) {
diff --git a/controllers/releases.php b/controllers/releases.php
index 69f47ec..882aee0 100644
--- a/controllers/releases.php
+++ b/controllers/releases.php
@@ -1,10 +1,10 @@
 <?php
-final class ReleasesController extends \TracToGitlab\Controller
+final class ReleasesController extends \TracToGitlab\GitlabController
 {
-    const CACHE_KEY = 'studip-releases';
+    const CACHE_KEY = 'releases';
 
     private static $curl_handle = null;
-    private $filesize_cache;
+    private StudipCachedArray $filesize_cache;
 
     public function before_filter(&$action, &$args)
     {
@@ -36,7 +36,7 @@ final class ReleasesController extends \TracToGitlab\Controller
         Sidebar::get()->addWidget($widget);
 
         $this->filesize_cache = new StudipCachedArray(
-            self::CACHE_KEY . '/filesizes',
+            'gitlab/releases/filesizes',
             strtotime('+10 years')
         );
     }
@@ -54,7 +54,7 @@ final class ReleasesController extends \TracToGitlab\Controller
     {
         $version = Request::get('version', Request::get('v'));
 
-        $this->releases = $this->getReleases();
+        $this->releases = $this->cached('releases', fn() => $this->getReleases());
 
         $this->addVersionFilter($this->releases, $version);
         $this->addResetAction();
@@ -69,7 +69,7 @@ final class ReleasesController extends \TracToGitlab\Controller
     public function reset_action()
     {
         if ($this->isAdmin()) {
-            StudipCacheFactory::getCache()->expire(self::CACHE_KEY);
+            $this->expireCache('releases');
             $this->filesize_cache->expire();
         }
 
@@ -78,96 +78,89 @@ final class ReleasesController extends \TracToGitlab\Controller
 
     private function getReleases(): array
     {
-        $cache = StudipCacheFactory::getCache();
-
-        $releases = $cache->read(self::CACHE_KEY);
-        if ($releases === false) {
-            $temp = array_map(
-                function (array $release): array {
-                    $links = array_filter(
-                        $release['assets']['links'] ?? [],
-                        function (array $asset): bool {
-                            return $asset['link_type'] === 'package';
-                        }
-                    );
-                    $links = array_map(
-                        function (array $link): array {
-                            return [
-                                'name' => $link['name'],
-                                'url'  => $link['url'],
-                                'size' => $this->getFileSize($link['url']),
-                            ];
-                        },
-                        $links
+        $temp = array_map(
+            function (array $release): array {
+                $links = array_filter(
+                    $release['assets']['links'] ?? [],
+                    function (array $asset): bool {
+                        return $asset['link_type'] === 'package';
+                    }
+                );
+                $links = array_map(
+                    function (array $link): array {
+                        return [
+                            'name' => $link['name'],
+                            'url'  => $link['url'],
+                            'size' => $this->getFileSize($link['url']),
+                        ];
+                    },
+                    $links
+                );
+                usort($links, function ($a, $b) {
+                    return strcmp($a['name'], $b['name']);
+                });
+
+                $changelog = sprintf(
+                    'https://gitlab.studip.de/studip/studip/-/blob/%s/ChangeLog%s',
+                    $release['tag_name'],
+                    $release['tag_name'] >= 'v5.3' ? '.md' : ''
+                );
+
+                try {
+                    $file = $this->gitlab->repositoryFiles()->getFile(
+                        $this->gitlab_project_id,
+                        'RELEASE-NOTES.md',
+                        $release['tag_name']
                     );
-                    usort($links, function ($a, $b) {
-                        return strcmp($a['name'], $b['name']);
-                    });
 
-                    $changelog = sprintf(
-                        'https://gitlab.studip.de/studip/studip/-/blob/%s/ChangeLog%s',
+                    $release_notes = sprintf(
+                        'https://gitlab.studip.de/studip/studip/-/blob/%s/%s',
                         $release['tag_name'],
-                        $release['tag_name'] >= 'v5.3' ? '.md' : ''
+                        $file['file_name']
                     );
-
-                    try {
-                        $file = $this->gitlab->repositoryFiles()->getFile(
-                            $this->gitlabProjectId,
-                            'RELEASE-NOTES.md',
-                            $release['tag_name']
-                        );
-
-                        $release_notes = sprintf(
-                            'https://gitlab.studip.de/studip/studip/-/blob/%s/%s',
-                            $release['tag_name'],
-                            $file['file_name']
-                        );
-                    } catch (Exception $e) {
-                        $release_notes = null;
-                    }
-
-                    return [
-                        'name'        => $release['name'],
-                        'description' => $release['description'],
-                        'url'         => $release['_links']['self'],
-                        'changelog'   => $changelog,
-                        'notes'       => $release_notes,
-                        'released'    => strtotime($release['released_at']),
-                        'sources'     => $release['assets']['sources'],
-                        'links'       => $links,
-                    ];
-                },
-                $this->gitlab->repositories()->releases($this->gitlabProjectId)
-            );
-
-            $releases = [];
-            foreach ($temp as $release) {
-                $version = $release['name'];
-                if (preg_match('/^v?(\d+(\.\d+)+)$/', $release['name'], $match)) {
-                    $version = $match[1];
+                } catch (Exception $e) {
+                    $release_notes = null;
                 }
 
-                $major_version = implode('.', array_slice(explode('.', $version), 0, 2));
+                return [
+                    'name'        => $release['name'],
+                    'description' => $release['description'],
+                    'url'         => $release['_links']['self'],
+                    'changelog'   => $changelog,
+                    'notes'       => $release_notes,
+                    'released'    => strtotime($release['released_at']),
+                    'sources'     => $release['assets']['sources'],
+                    'links'       => $links,
+                ];
+            },
+            $this->gitlab->repositories()->releases($this->gitlab_project_id)
+        );
 
-                if (!isset($releases[$major_version])) {
-                    $releases[$major_version] = [];
-                }
+        $releases = [];
+        foreach ($temp as $release) {
+            $version = $release['name'];
+            if (preg_match('/^v?(\d+(\.\d+)+)$/', $release['name'], $match)) {
+                $version = $match[1];
+            }
 
-                $releases[$major_version][$version] = $release;
+            $major_version = implode('.', array_slice(explode('.', $version), 0, 2));
+
+            if (!isset($releases[$major_version])) {
+                $releases[$major_version] = [];
             }
 
-            uksort($releases, function ($a, $b) {
+            $releases[$major_version][$version] = $release;
+        }
+
+        uksort($releases, function ($a, $b) {
+            return version_compare($b, $a);
+        });
+        foreach ($releases as $version => $subversions) {
+            uksort($subversions, function ($a, $b) {
                 return version_compare($b, $a);
             });
-            foreach ($releases as $version => $subversions) {
-                uksort($subversions, function ($a, $b) {
-                    return version_compare($b, $a);
-                });
-
-                $releases[$version] = $subversions;
-            }
 
-            $cache->write(self::CACHE_KEY, $releases);
+            $releases[$version] = $subversions;
         }
 
         return $releases;
diff --git a/controllers/users.php b/controllers/users.php
index ec4a6bc..b56d483 100644
--- a/controllers/users.php
+++ b/controllers/users.php
@@ -10,16 +10,16 @@ final class UsersController extends TracToGitlab\EventController
         }
 
         if (
-            !$this->verifySecret(self::SECRET_USER_CREATED)
+            !$this->verifySecret(Config::get()->TRAC2GITLAB_GITLAB_SYSTEMHOOK_SECRET)
             || !$this->verifyEventType('System Hook')
         ) {
             throw new AccessDeniedException();
         }
 
-        if ($this->getFromPayload('event_name') === 'user_create') {
+        if ($this->payload('event_name') === 'user_create') {
             $this->gitlab->projects()->addMember(
-                $this->gitlabProjectId,
-                $this->getFromPayload('user_id'),
+                $this->gitlab_project_id,
+                $this->payload('user_id'),
                 20
             );
         }
diff --git a/controllers/webhooks.php b/controllers/webhooks.php
new file mode 100644
index 0000000..292d9bd
--- /dev/null
+++ b/controllers/webhooks.php
@@ -0,0 +1,80 @@
+<?php
+
+use TracToGitlab\EventController;
+use TracToGitlab\EventHandler;
+use TracToGitlab\EventHandlers;
+
+final class WebhooksController extends EventController
+{
+    public function before_filter(&$action, &$args)
+    {
+        if (!Request::isPost()) {
+            throw new MethodNotAllowedException();
+        }
+
+        if (!$this->verifySecret(Config::get()->TRAC2GITLAB_GITLAB_WEBHOOK_SECRET)) {
+            throw new AccessDeniedException();
+        }
+
+        parent::before_filter($action, $args);
+    }
+
+    public function gitlab_action()
+    {
+        $payload = $this->payload();
+
+        if (!$this->verifyEvent($payload)) {
+            $this->set_status(400, 'This is not a valid gitlab event');
+        }
+
+        $event_type = $payload['object_kind'];
+
+        foreach ($this->getKnownHandlers() as $handler) {
+            if (
+                !$handler->shouldReactToEventHeader($_SERVER['X-GITLAB-EVENT'])
+                || !$handler->shouldReactToEventType($event_type)
+            ) {
+                continue;
+            }
+
+            $result = $handler($payload);
+            if ($result === false) {
+                break;
+            }
+        }
+
+        $this->render_nothing();
+    }
+
+    private function verifyEvent(array $payload): bool
+    {
+        if (!isset($_SERVER['X-GITLAB-EVENT'])) {
+            return false;
+        }
+
+        if (!isset($payload['object_kind'])) {
+            return false;
+        }
+
+        if (!isset($payload['project']['id'])) {
+            return false;
+        }
+
+        if ($payload['project']['id'] !== $this->gitlab_project_id) {
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * @return iterator|EventHandler[]
+     */
+    private function getKnownHandlers(): iterator
+    {
+        yield app(EventHandlers\AllAutomaticJobsHaveSucceeded::class);
+        yield app(EventHandlers\BranchDeleted::class);
+        yield app(EventHandlers\BuildImageJobSucceeded::class);
+        yield app(EventHandlers\IssueCreated::class);
+    }
+}
diff --git a/lib/BytistConnector.php b/lib/BytistConnector.php
new file mode 100644
index 0000000..9cec712
--- /dev/null
+++ b/lib/BytistConnector.php
@@ -0,0 +1,35 @@
+<?php
+namespace TracToGitlab;
+
+use GuzzleHttp\Psr7\Request;
+use Psr\Http\Client\ClientInterface;
+
+final class BytistConnector
+{
+    private string $token;
+
+    public function __construct(string $token)
+    {
+        $this->token = $token;
+    }
+
+    public function deploy(string $branch): void
+    {
+        $this->sendCommandToBranch('deploy', $branch);
+    }
+
+    public function decommission(string $branch): void
+    {
+        $this->sendCommandToBranch('decomission', $branch);
+    }
+
+    private function sendCommandToBranch(string $command, string $branch): void
+    {
+        $request = new Request(
+            'POST',
+            "https://studip.bytist.de/{$command}/{$branch}",
+            'Authorization: Basic '. base64_encode($this->token)
+        );
+        app(ClientInterface::class)->sendRequest($request);
+    }
+}
diff --git a/lib/Controller.php b/lib/Controller.php
index 09a987d..1f2214e 100644
--- a/lib/Controller.php
+++ b/lib/Controller.php
@@ -3,27 +3,9 @@ namespace TracToGitlab;
 
 /**
  * @property \TracToGitlabPlugin $plugin
- * @property TracLookup $trac
- * @property \Gitlab\Client $gitlab
- * @property \Gitlab\ResultPager $gitlabPager
- * @property int $gitlabProjectId
  */
 abstract class Controller extends \PluginController
 {
-    public function &__get($offset)
-    {
-        $result = &parent::__get($offset);
-
-        if ($result === null) {
-            $container = $this->plugin->getDIContainer();
-            if (isset($container[$offset])) {
-                return $container[$offset];
-            }
-        }
-
-        return $result;
-    }
-
     protected function activateNavigation(string ...$path): void
     {
         \Navigation::activateItem('/gitlab-dashboard/' . implode('/', $path));
diff --git a/lib/EventController.php b/lib/EventController.php
index bfc6d99..8e2d627 100644
--- a/lib/EventController.php
+++ b/lib/EventController.php
@@ -1,7 +1,7 @@
 <?php
 namespace TracToGitlab;
 
-abstract class EventController extends Controller
+abstract class EventController extends GitlabController
 {
     private $payload = null;
 
@@ -15,7 +15,7 @@ abstract class EventController extends Controller
         return $_SERVER['HTTP_X_GITLAB_EVENT'] === $type;
     }
 
-    protected function getFromPayload(...$keys)
+    protected function payload(...$keys)
     {
         if ($this->payload === null) {
             $this->payload = $this->getPayloadFromRequest() ?? false;
@@ -38,7 +38,7 @@ abstract class EventController extends Controller
 
     private function getPayloadFromRequest(): ?array
     {
-        $input =file_get_contents('php://input');
+        $input = file_get_contents('php://input');
         if (!$input) {
             return null;
         }
diff --git a/lib/EventHandler.php b/lib/EventHandler.php
new file mode 100644
index 0000000..1c47484
--- /dev/null
+++ b/lib/EventHandler.php
@@ -0,0 +1,9 @@
+<?php
+namespace TracToGitlab;
+
+interface EventHandler
+{
+    public function shouldReactToEventHeader(string $event_header): bool;
+    public function shouldReactToEventType(string $event_type): bool;
+    public function __invoke(array $payload);
+}
diff --git a/lib/EventHandlers/AllAutomaticJobsHaveSucceeded.php b/lib/EventHandlers/AllAutomaticJobsHaveSucceeded.php
new file mode 100644
index 0000000..b1ae5fd
--- /dev/null
+++ b/lib/EventHandlers/AllAutomaticJobsHaveSucceeded.php
@@ -0,0 +1,53 @@
+<?php
+namespace TracToGitlab\EventHandlers;
+
+use Gitlab;
+use TracToGitlab\Hooks;
+use TracToGitlab\Traits\GetBuildImageJob;
+
+final class AllAutomaticJobsHaveSucceeded extends Hooks\Pipeline
+{
+    use GetBuildImageJob;
+
+    private Gitlab\Client $gitlab_client;
+
+    public function __construct(Gitlab\Client $client)
+    {
+        $this->gitlab_client = $client;
+    }
+
+    public function __invoke(array $payload)
+    {
+        if (
+            isset($payload['merge_request'], $payload['builds'])
+            && $this->allAutomaticsHaveSucceeded($payload['builds'])
+            && !$this->buildImageJobHasStarted($payload['builds'])
+        ) {
+            $this->gitlab_client->jobs()->play(
+                $payload['project']['id'],
+                $this->getBuildImageJob($payload['builds'])['id']
+            );
+        }
+    }
+
+    private function allAutomaticsHaveSucceeded($builds): bool
+    {
+        $builds = array_filter($builds, function ($build): bool {
+            return !$build['manual'];
+        });
+
+        foreach ($builds as $build) {
+            if ($build['status'] !== 'success') {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    private function buildImageJobHasStarted($builds): bool
+    {
+        $job = $this->getBuildImageJob($builds);
+        return $job && $job['status'] !== 'manual';
+    }
+}
diff --git a/lib/EventHandlers/BranchDeleted.php b/lib/EventHandlers/BranchDeleted.php
new file mode 100644
index 0000000..c0cf214
--- /dev/null
+++ b/lib/EventHandlers/BranchDeleted.php
@@ -0,0 +1,37 @@
+<?php
+namespace TracToGitlab\EventHandlers;
+
+use TracToGitlab\BytistConnector;
+use TracToGitlab\Hooks;
+use TracToGitlab\Models\GitlabReviewApp;
+
+final class BranchDeleted extends Hooks\Push
+{
+    private BytistConnector $connector;
+
+    public function __construct(BytistConnector $connector)
+    {
+        $this->connector = $connector;
+    }
+
+    public function __invoke(array $payload)
+    {
+        if ($this->isBranchDeleted($payload)) {
+            $branch = substr($payload['ref'], strlen('refs/heads/'));
+
+            $this->connector->decommission($branch);
+            GitlabReviewApp::deleteBySQL('branch = ?', [$branch]);
+        }
+    }
+
+    /**
+     * @see https://gitlab.com/gitlab-org/gitlab/-/issues/25305#note_215544681
+     */
+    private function isBranchDeleted($payload): bool
+    {
+        return array_key_exists('after', $payload)
+            && $payload['after'] === '0000000000000000000000000000000000000000'
+            && array_key_exists('checkout_sha', $payload)
+            && $payload['checkout_sha'] === null;
+    }
+}
diff --git a/lib/EventHandlers/BuildImageJobSucceeded.php b/lib/EventHandlers/BuildImageJobSucceeded.php
new file mode 100644
index 0000000..a6875a1
--- /dev/null
+++ b/lib/EventHandlers/BuildImageJobSucceeded.php
@@ -0,0 +1,42 @@
+<?php
+namespace TracToGitlab\EventHandlers;
+
+use TracToGitlab\BytistConnector;
+use TracToGitlab\Hooks;
+use TracToGitlab\Models\GitlabReviewApp;
+use TracToGitlab\Traits\GetBuildImageJob;
+
+final class BuildImageJobSucceeded extends Hooks\Pipeline
+{
+    use GetBuildImageJob;
+
+    private BytistConnector $connector;
+
+    public function __construct(BytistConnector $connector)
+    {
+        $this->connector = $connector;
+    }
+
+    public function __invoke(array $payload)
+    {
+        if (
+            isset(
+                $payload['merge_request'],
+                $payload['builds'],
+                $payload['object_attributes']['ref']
+            )
+            && $this->buildImageJobHasSucceeded($payload['builds'])
+        ) {
+            $branch = $payload['object_attributes']['ref'];
+
+            $this->connector->deploy($branch);
+            GitlabReviewApp::create(['branch' => $branch]);
+        }
+    }
+
+    private function buildImageJobHasSucceeded(array $builds): bool
+    {
+        $job = $this->getBuildImageJob($builds);
+        return $job && $job['status'] === 'success';
+    }
+}
diff --git a/lib/EventHandlers/IssueCreated.php b/lib/EventHandlers/IssueCreated.php
new file mode 100644
index 0000000..75fa9b4
--- /dev/null
+++ b/lib/EventHandlers/IssueCreated.php
@@ -0,0 +1,165 @@
+<?php
+namespace TracToGitlab\EventHandlers;
+
+use CoreForum;
+use DBManager;
+use ForumCat;
+use ForumEntry;
+use Gitlab;
+use Parsedown;
+use PluginEngine;
+use Studip\Markup;
+use TracToGitlab\Hooks;
+use URLHelper;
+use User;
+
+final class IssueCreated extends Hooks\Issue
+{
+    private Gitlab\Client $gitlab_client;
+    private Parsedown $parsedown;
+
+    public function __construct(Gitlab\Client $gitlab_client, Parsedown $parsedown)
+    {
+        $this->gitlab_client = $gitlab_client;
+        $this->parsedown = $parsedown;
+    }
+
+    public function __invoke(array $payload)
+    {
+        if ($payload['object_attributes']['action'] !== 'open') {
+            return;
+        }
+
+        $username = $payload['user']['username'];
+        $email = $payload['user']['email'];
+
+        $user = User::findOneByUsername($username)
+            ?? User::findOneByEmail($email)
+            ?? User::findOneByUsername('gitlab-bot');
+
+        if (!$user) {
+            return;
+        }
+
+        $labels = $payload['labels'];
+        foreach ($this->getLabelMapping() as $label => $definition) {
+            if (!$this->hasLabel($labels, $label)) {
+                continue;
+            }
+            $title = sprintf(
+                $definition['subject'],
+                $payload['object_attributes']['iid'],
+                $payload['object_attributes']['title']
+            );
+            $body = Markup::markAsHtml(
+                $this->parsedown->text(
+                    sprintf(
+                        $definition['body'],
+                        $payload['object_attributes']['description'],
+                        $payload['object_attributes']['iid'],
+                        $payload['object_attributes']['url']
+                    )
+                )
+            );
+
+            $url = $this->createForumEntry(
+                $label,
+                $user,
+                $title,
+                $body,
+                $definition['course_id']
+            );
+
+            if (!$url) {
+                continue;
+            }
+
+            $this->gitlab_client->issues()->addNote(
+                $payload['project']['id'],
+                $payload['object_attributes']['iid'],
+                "[Zum Forenbeitrag auf dem Entwicklungsserver]({$url})"
+            );
+        }
+    }
+
+    private function hasLabel(array $labels, string $needle): bool
+    {
+        foreach ($labels as $label) {
+            if ($label['title'] === $needle) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * @return ?string Absolute URL to forum posting
+     */
+    private function createForumEntry(string $type, User $user, string $subject, string $body, string $course_id)
+    {
+        if (!PluginEngine::getPlugin(CoreForum::class)) {
+            return null;
+        }
+
+        $query = "SELECT category_id
+                  FROM forum_categories
+                  WHERE seminar_id = ?
+                    AND entry_name LIKE CONCAT('Aktive ', ?, '%')";
+        $category_id = DBManager::get()->fetchColumn($query, [$course_id, $type]);
+
+        $topic_id = md5(uniqid('CoreForum'));
+        ForumEntry::insert([
+            'topic_id'    => $topic_id,
+            'seminar_id'  => $course_id,
+            'user_id'     => $user->id,
+            'name'        => $subject,
+            'content'     => $subject,
+            'author'      => $user->getFullName(),
+            'author_host' => $_SERVER['REMOTE_ADDR']
+        ], $course_id);
+
+        if ($category_id) {
+            ForumCat::addArea($category_id, $topic_id);
+        }
+
+        $topic_id2 = md5(uniqid('CoreForum'));
+        ForumEntry::insert([
+            'topic_id'    => $topic_id2,
+            'seminar_id'  => $course_id,
+            'user_id'     => $user->id,
+            'name'        => $subject,
+            'content'     => $body,
+            'author'      => $user->getFullName(),
+            'author_host' => $_SERVER['REMOTE_ADDR']
+        ], $topic_id);
+
+        $old_base = URLHelper::setBaseURL($GLOBALS['ABSOLUTE_URI_STUDIP']);
+        $url = URLHelper::getURL(
+            "dispatch.php/course/forum/index/index/{$topic_id2}#{$topic_id2}",
+            ['cid' => $course_id]
+        );
+        URLHelper::setBaseURL($old_base);
+
+        return $url;
+    }
+
+    /**
+     * @return array[]
+     */
+    protected function getLabelMapping(): array
+    {
+        return [
+            'TIC'  => [
+                'course_id' => '1927f2b86d6b185aa6c6697810ad42f1',
+                'subject'   => 'TIC #%1$u: %2$s',
+                'body'      => "%1\$s\n\n----\nZum [gitlab-Issue #%2\$u](%3\$s)",
+            ],
+            'StEP' => [
+                'course_id' => '1927f2b86d6b185aa6c6697810ad42f1',
+                'subject'   => 'StEP00%1$u: %2$s',
+                'body'      => "%1\$s\n\n----\nZum [gitlab-Issue #%2\$u](%3\$s)",
+            ],
+        ];
+    }
+}
diff --git a/lib/GitlabController.php b/lib/GitlabController.php
index e4ac764..a1275d7 100644
--- a/lib/GitlabController.php
+++ b/lib/GitlabController.php
@@ -1,43 +1,26 @@
 <?php
 namespace TracToGitlab;
 
-use Gitlab\Api\AbstractApi;
+use Config;
+use Gitlab;
+use TracToGitlab\Traits\Cached;
 
 abstract class GitlabController extends Controller
 {
-    private $gitlabCache;
+    use Cached;
+
+    protected int $gitlab_project_id;
+    protected Gitlab\Client $gitlab;
+    protected Gitlab\ResultPager $result_pager;
 
     public function before_filter(&$action, &$args)
     {
         parent::before_filter($action, $args);
 
-        $this->gitlabCache = \StudipCacheFactory::getCache();
-    }
-
-    protected function readFromCache(string $key, bool $raw = false)
-    {
-        $cached = $this->gitlabCache->read($key);
-        if (!$cached) {
-            return false;
-        }
-        $data = json_decode($cached, true);
-        return $raw ? $data : $data['data'];
-    }
+        $this->setCache(\StudipCacheFactory::getCache(), 'gitlab/');
 
-    protected function writeToCache(string $key, $data, $expire = 5 * 60)
-    {
-        $this->gitlabCache->write($key, json_encode([
-            'data' => $data,
-            'time' => time(),
-        ]), $expire);
-    }
-
-    protected function getCachedDate(string $key)
-    {
-        $data = $this->readFromCache($key, true);
-        if ($data === false) {
-            return false;
-        }
-        return $data['time'] ?? false;
+        $this->gitlab_project_id = Config::get()->TRAC2GITLAB_GITLAB_PROJECT_ID;
+        $this->gitlab = app(Gitlab\Client::class);
+        $this->result_pager = app(Gitlab\ResultPager::class);
     }
 }
diff --git a/lib/GitlabIssue.php b/lib/GitlabIssue.php
index 00f63bd..c08f62a 100644
--- a/lib/GitlabIssue.php
+++ b/lib/GitlabIssue.php
@@ -1,17 +1,19 @@
 <?php
 namespace TracToGitlab;
 
+use TracToGitlab\Models\GitlabReviewApp;
+
 final class GitlabIssue implements \JsonSerializable
 {
     const QM_LABEL_MAPPING = [
-        'Anwendungs-Doku'   => 'userdoc',
-        'Barrierefreiheit'  => 'a11y',
         'Code-Review'       => 'code',
-        'Entwicklungs-Doku' => 'devdoc',
-        'Funktionalität'    => 'func',
         'GUI-Richtlinien'   => 'gui',
+        'Funktionalität'    => 'func',
         'Schnittstellen'    => 'iface',
+        'Barrierefreiheit'  => 'a11y',
         'Textstrings'       => 'text',
+        'Entwicklungs-Doku' => 'devdoc',
+        'Anwendungs-Doku'   => 'userdoc',
     ];
 
     private $issue;
@@ -57,44 +59,32 @@ final class GitlabIssue implements \JsonSerializable
         return $this->issue['merge_requests_count'] > 0;
     }
 
-    public function isMerged()
+    public function isMerged(): bool
     {
+        if (!$this->hasMergeRequests()) {
+            return false;
+        }
+
         return count(array_filter($this->mrs, function ($mr) {
-            return $mr['state'] !== 'merged';
+            return $mr['state'] === 'opened';
         })) === 0;
     }
 
-    public function isBiest()
+    public function isBiest(): bool
     {
         return in_array('BIEST', $this->issue['labels']);
     }
 
-    public function isTic()
+    public function isTic(): bool
     {
         return in_array('TIC', $this->issue['labels']);
     }
 
-    public function isStep()
+    public function isStep(): bool
     {
         return in_array('StEP', $this->issue['labels']);
     }
 
-    public function getIconForQMLabel(string $label): string
-    {
-        $state = $this->getStateForQMLabel($label);
-        if ($state === '+') {
-            return \Icon::create('accept', \Icon::ROLE_STATUS_GREEN);
-        }
-        if ($state === '?') {
-            return \Icon::create('question', \Icon::ROLE_STATUS_YELLOW);
-        }
-        if ($state === '-') {
-            return \Icon::create('decline', \Icon::ROLE_STATUS_RED);
-        }
-
-        return '';
-    }
-
     public function getStateForQMLabel(string $label): ?string
     {
         foreach ($this->issue['labels'] as $l) {
@@ -105,7 +95,7 @@ final class GitlabIssue implements \JsonSerializable
         return null;
     }
 
-    public function getStepStatus(): ? string
+    public function getStepStatus(): ?string
     {
         foreach ($this->issue['labels'] as $l) {
             if (preg_match("/^StEP::(.+)$/", $l, $match)) {
@@ -115,6 +105,22 @@ final class GitlabIssue implements \JsonSerializable
         return null;
     }
 
+    public function getReviewApps(): array
+    {
+        $mr_branches = array_column($this->mrs, 'source_branch');
+        $mr_branches = array_unique($mr_branches);
+        $mr_branches = array_filter($mr_branches, function (string $branch): bool {
+            return 0 < GitlabReviewApp::countBySql(
+                'branch = ? AND chdate < UNIX_TIMESTAMP(NOW() - INTERVAL 2 MINUTE)',
+                    [$branch]
+            );
+        });
+        return array_map(
+            fn(string $branch) => "https://{$branch}.studip.bytist.de",
+            $mr_branches
+        );
+    }
+
     public function jsonSerialize()
     {
         $result = [
@@ -141,6 +147,7 @@ final class GitlabIssue implements \JsonSerializable
 
                 return $reviewers;
             }, []),
+            'review_apps' => $this->getReviewApps(),
             'qm_states' => array_combine(
                 array_values(self::QM_LABEL_MAPPING),
                 array_map(function ($qm) {
diff --git a/lib/Hooks/Issue.php b/lib/Hooks/Issue.php
new file mode 100644
index 0000000..9353f03
--- /dev/null
+++ b/lib/Hooks/Issue.php
@@ -0,0 +1,17 @@
+<?php
+namespace TracToGitlab\Hooks;
+
+use TracToGitlab\EventHandler;
+
+abstract class Issue implements EventHandler
+{
+    public function shouldReactToEventHeader(string $event_header): bool
+    {
+        return $event_header === 'Issue Hook';
+    }
+
+    public function shouldReactToEventType(string $event_type): bool
+    {
+        return $event_type === 'issue';
+    }
+}
diff --git a/lib/Hooks/Pipeline.php b/lib/Hooks/Pipeline.php
new file mode 100644
index 0000000..c28a047
--- /dev/null
+++ b/lib/Hooks/Pipeline.php
@@ -0,0 +1,17 @@
+<?php
+namespace TracToGitlab\Hooks;
+
+use TracToGitlab\EventHandler;
+
+abstract class Pipeline implements EventHandler
+{
+    public function shouldReactToEventHeader(string $event_header): bool
+    {
+        return $event_header === 'Pipeline Hook';
+    }
+
+    public function shouldReactToEventType(string $event_type): bool
+    {
+        return $event_type === 'pipeline';
+    }
+}
diff --git a/lib/Hooks/Push.php b/lib/Hooks/Push.php
new file mode 100644
index 0000000..8a37218
--- /dev/null
+++ b/lib/Hooks/Push.php
@@ -0,0 +1,17 @@
+<?php
+namespace TracToGitlab\Hooks;
+
+use TracToGitlab\EventHandler;
+
+abstract class Push implements EventHandler
+{
+    public function shouldReactToEventHeader(string $event_header): bool
+    {
+        return $event_header === 'Push Hook';
+    }
+
+    public function shouldReactToEventType(string $event_type): bool
+    {
+        return $event_type === 'push';
+    }
+}
diff --git a/lib/Models/GitlabReviewApp.php b/lib/Models/GitlabReviewApp.php
new file mode 100644
index 0000000..b96dd42
--- /dev/null
+++ b/lib/Models/GitlabReviewApp.php
@@ -0,0 +1,12 @@
+<?php
+namespace TracToGitlab\Models;
+
+final class GitlabReviewApp extends \SimpleORMap
+{
+    protected static function configure($config = [])
+    {
+        $config['db_table'] = 'gitlab_review_apps';
+
+        parent::configure($config);
+    }
+}
diff --git a/lib/Plugin.php b/lib/Plugin.php
index 4bdf219..8badf74 100644
--- a/lib/Plugin.php
+++ b/lib/Plugin.php
@@ -3,50 +3,53 @@ namespace TracToGitlab;
 
 use Config;
 use Gitlab;
-use GuzzleHttp;
-use Http;
-use Parsedown;
 use Pimple;
+use Psr\Http\Client\ClientInterface;
+use Studip\DIContainer;
 
 abstract class Plugin extends \StudIPPlugin
 {
-    private $container = null;
-
-    public function getDIContainer()
+    protected function setupDiContainer()
     {
-        if ($this->container === null) {
-            require_once $this->getPluginPath() . '/vendor/autoload.php';
-
-            $this->container = new Pimple\Container();
-
-            $this->container['gitlab'] = function () {
-                $builder = new Gitlab\HttpClient\Builder(
-                    new GuzzleHttp\Client(),
-                    new Http\Factory\Guzzle\RequestFactory(),
-                    new Http\Factory\Guzzle\StreamFactory(),
-                    new Http\Factory\Guzzle\UriFactory()
-                );
-
-                $client = new Gitlab\Client($builder);
-                $client->setUrl(Config::get()->TRAC2GITLAB_GITLAB_URL);
-                $client->authenticate(Config::get()->TRAC2GITLAB_GITLAB_TOKEN, Gitlab\Client::AUTH_HTTP_TOKEN);
-                return $client;
-            };
+        $container = DIContainer::getInstance();
+        $container->set(BytistConnector::class, function () {
+            return new BytistConnector(Config::get()->TRAC2GITLAB_BYTIST_TOKEN);
+        });
+        $container->set(ClientInterface::class, function () {
+            return new \GuzzleHttp\Client();
+        });
+        $container->set(TracLookup::class, function () {
+            return new TracLookup(Config::get()->TRAC2GITLAB_TRAC_URL);
+        });
+        $container->set(Gitlab\Client::class, function (ClientInterface $client) {
+            $builder = new Gitlab\HttpClient\Builder(
+                $client,
+                new \Http\Factory\Guzzle\RequestFactory(),
+                new \Http\Factory\Guzzle\StreamFactory(),
+                new \Http\Factory\Guzzle\UriFactory()
+            );
+
+            $client = new Gitlab\Client($builder);
+            $client->setUrl(Config::get()->TRAC2GITLAB_GITLAB_URL);
+            $client->authenticate(Config::get()->TRAC2GITLAB_GITLAB_TOKEN, Gitlab\Client::AUTH_HTTP_TOKEN);
+            return $client;
+        });
+        $container->set(Gitlab\ResultPager::class, function (Gitlab\Client $client) {
+            return new Gitlab\ResultPager($client);
+        });
+        $container->set(Parsedown::class, function () {
+            $parsedown = new Parsedown();
+            $parsedown->setSafeMode(true);
+            return $parsedown;
+        });
+    }
 
-            $this->container['gitlabProjectId'] = function () {
-                return Config::get()->TRAC2GITLAB_GITLAB_PROJECT_ID;
-            };
+    public function perform($unconsumed_path)
+    {
+        require_once $this->getPluginPath() . '/vendor/autoload.php';
 
-            $this->container['gitlabPager'] = function ($c) {
-                return new Gitlab\ResultPager($c['gitlab']);
-            };
+        $this->setupDiContainer();
 
-            $this->container['parsedown'] = function () {
-                $parsedown = new Parsedown();
-                $parsedown->setSafeMode(true);
-                return $parsedown;
-            };
-        }
-        return $this->container;
+        parent::perform($unconsumed_path);
     }
 }
diff --git a/lib/Traits/Cached.php b/lib/Traits/Cached.php
new file mode 100644
index 0000000..f2d8692
--- /dev/null
+++ b/lib/Traits/Cached.php
@@ -0,0 +1,65 @@
+<?php
+namespace TracToGitlab\Traits;
+
+use StudipCache;
+
+trait Cached
+{
+    protected ?StudipCache $cache = null;
+    protected string $prefix = '';
+
+    protected function setCache(?StudipCache $cache = null, string $prefix = ''): void
+    {
+        $this->cache = $cache;
+        $this->prefix = $prefix;
+    }
+
+    protected function cached(string $key, callable $generator, int $expire = 5 * 60)
+    {
+        $cached = $this->readFromCache($this->getCacheKey($key));
+        if ($cached !== false) {
+            return $cached;
+        }
+
+        $data = $generator();
+        $this->writeToCache($this->getCacheKey($key), $data, $expire);
+        return $data;
+    }
+
+    protected function readFromCache(string $key, bool $raw = false)
+    {
+        $cached = $this->cache->read($this->getCacheKey($key));
+        if (!$cached) {
+            return false;
+        }
+        $data = json_decode($cached, true);
+        return $raw ? $data : $data['data'];
+    }
+
+    protected function writeToCache(string $key, $data, $expire = 5 * 60): void
+    {
+        $this->cache->write($this->getCacheKey($key), json_encode([
+            'data' => $data,
+            'time' => time(),
+        ]), $expire);
+    }
+
+    protected function expireCache(string $key): void
+    {
+        $this->cache->expire($this->getCacheKey($key));
+    }
+
+    protected function getCachedDate(string $key): ?int
+    {
+        $data = $this->readFromCache($this->getCacheKey($key), true);
+        if ($data === false) {
+            return null;
+        }
+        return $data['time'] ?? null;
+    }
+
+    private function getCacheKey(string $key): string
+    {
+        return $this->prefix . $key;
+    }
+}
diff --git a/lib/Traits/GetBuildImageJob.php b/lib/Traits/GetBuildImageJob.php
new file mode 100644
index 0000000..85c578e
--- /dev/null
+++ b/lib/Traits/GetBuildImageJob.php
@@ -0,0 +1,16 @@
+<?php
+namespace TracToGitlab\Traits;
+
+trait GetBuildImageJob
+{
+    private function getBuildImageJob(array $builds): ?array
+    {
+        foreach ($builds as $build) {
+            if ($build['name'] === 'build_image') {
+                return $build;
+            }
+        }
+
+        return null;
+    }
+}
diff --git a/migrations/6_setup_bytist_connection.php b/migrations/6_setup_bytist_connection.php
new file mode 100644
index 0000000..ac7a7bb
--- /dev/null
+++ b/migrations/6_setup_bytist_connection.php
@@ -0,0 +1,19 @@
+<?php
+final class SetupBytistConnection extends Migration
+{
+    public function up()
+    {
+        Config::get()->create('TRAC2GITLAB_BYTIST_TOKEN', [
+            'value'       => '',
+            'type'        => 'string',
+            'range'       => 'global',
+            'section'     => 'Trac2Gitlab',
+            'description' => 'Token für die Verbindung zu Bytist',
+        ]);
+    }
+
+    protected function down()
+    {
+        Config::get()->delete('TRAC2GITLAB_BYTIST_TOKEN');
+    }
+}
diff --git a/migrations/7_add_webhook_secret_configuration.php b/migrations/7_add_webhook_secret_configuration.php
new file mode 100644
index 0000000..7d1e41e
--- /dev/null
+++ b/migrations/7_add_webhook_secret_configuration.php
@@ -0,0 +1,20 @@
+<?php
+final class AddWebhookSecretConfiguration extends Migration
+{
+    protected function up()
+    {
+        Config::get()->create('TRAC2GITLAB_GITLAB_WEBHOOK_SECRET', [
+            'value'       => '',
+            'type'        => 'string',
+            'range'       => 'global',
+            'section'     => 'Trac2Gitlab',
+            'description' => 'Secret für den Webhook bei Gitlab',
+        ]);
+    }
+
+    protected function down()
+    {
+        Config::get()->delete('TRAC2GITLAB_GITLAB_WEBHOOK_SECRET');
+    }
+
+}
diff --git a/migrations/8_setup_database.php b/migrations/8_setup_database.php
new file mode 100644
index 0000000..3de29f3
--- /dev/null
+++ b/migrations/8_setup_database.php
@@ -0,0 +1,20 @@
+<?php
+final class SetupDatabase extends Migration
+{
+    protected function up()
+    {
+        $query = "CREATE TABLE IF NOT EXISTS `gitlab_review_apps` (
+                    `branch` VARCHAR(255) COLLATE `latin1_bin` NOT NULL,
+                    `mkdate` INT(11) UNSIGNED NOT NULL,
+                    `chdate` INT(11) UNSIGNED NOT NULL, 
+                    PRIMARY KEY (`branch`)   
+                  )";
+        DBManager::get()->exec($query);
+    }
+
+    protected function down()
+    {
+        $query = "DROP TABLE IF EXISTS `gitlab_review_apps`";
+        DBManager::get()->exec($query);
+    }
+}
diff --git a/migrations/9_add_systemhook_secret_configuration.php b/migrations/9_add_systemhook_secret_configuration.php
new file mode 100644
index 0000000..aae8f09
--- /dev/null
+++ b/migrations/9_add_systemhook_secret_configuration.php
@@ -0,0 +1,19 @@
+<?php
+return new class extends Migration {
+    protected function up()
+    {
+        Config::get()->create('TRAC2GITLAB_GITLAB_SYSTEMHOOK_SECRET', [
+            'value'       => '',
+            'type'        => 'string',
+            'range'       => 'global',
+            'section'     => 'Trac2Gitlab',
+            'description' => 'Secret für den Systemhook bei Gitlab',
+        ]);
+    }
+
+    protected function down()
+    {
+        Config::get()->delete('TRAC2GITLAB_GITLAB_SYSTEMHOOK_SECRET');
+    }
+
+};
diff --git a/views/admin/index.php b/views/admin/index.php
new file mode 100644
index 0000000..739a8f7
--- /dev/null
+++ b/views/admin/index.php
@@ -0,0 +1,55 @@
+<?php
+/**
+ * @var AdminController $controller
+ * @var string $trac_url
+ * @var string $gitlab_url
+ * @var string $gitlab_token
+ * @var string $gitlab_project_id
+ * @var string $bytist_token
+ * @var string $webhook_secret
+ * @var string $systemhook_secret
+ */
+?>
+<form action="<?= $controller->store() ?>" method="post" class="default">
+    <fieldset>
+        <legend><?= _('GitLab') ?></legend>
+
+        <label>
+            <?= _('URL') ?>
+            <input type="text" name="gitlab-url" value="<?= htmlReady($gitlab_url) ?>">
+        </label>
+
+        <label>
+            <?= _('Token') ?>
+            <input type="text" name="gitlab-token" value="<?= htmlReady($gitlab_token) ?>">
+        </label>
+
+        <label>
+            <?= _('Id des Stud.IP-Projekts') ?>
+            <input type="number" name="gitlab-project-id" value="<?= htmlReady($gitlab_project_id) ?>">
+        </label>
+
+        <label>
+            <?= _('Secret für den Webhook') ?>
+            <input type="text" name="webhook-secret" value="<?= htmlReady($webhook_secret) ?>">
+        </label>
+
+        <label>
+            <?= _('Secret für den Sstemhook') ?>
+            <input type="text" name="systemhook-secret" value="<?= htmlReady($systemhook_secret) ?>">
+        </label>
+    </fieldset>
+
+    <fieldset>
+        <legend>Bytist</legend>
+
+        <label>
+            <?= _('Token') ?>
+            <input type="text" name="bytist-token" value="<?= htmlReady($bytist_token) ?>">
+        </label>
+    </fieldset>
+
+    <footer>
+        <?= Studip\Button::createAccept(_('Speichern')) ?>
+    </footer>
+</form>
diff --git a/views/dashboard/index.php b/views/dashboard/index.php
index 60267d8..f9a5985 100644
--- a/views/dashboard/index.php
+++ b/views/dashboard/index.php
@@ -6,14 +6,15 @@
  * @var array<string, string> $filters
  */
 
-$attributes = [
-    'data-issues'    => json_encode($issues),
-    'data-qm-labels' => json_encode($mapping),
-    'data-filters'   => json_encode($filters ?: null),
-    'data-filter-store-url' => $controller->store_filtersURL(),
+$vueData = [
+    'issues'          => $issues,
+    'qmLabels'        => $mapping,
+    'storedFilters'   => $filters ?: null,
+    'storeFiltersUrl' => $controller->store_filtersURL(),
 ];
+
 ?>
-<div id="dashboard" v-cloak <?= arrayToHtmlAttributes($attributes) ?>>
+<div id="dashboard-app" v-cloak data-vue-app-data="<?= htmlReady(json_encode($vueData)) ?>">
     <table class="default">
         <caption>
             <span v-if="filteredIssues.length !== issues.length">
@@ -79,6 +80,13 @@ $attributes = [
                     <a :href="issue.web_url" target="_blank">
                         {{ issue.title }}
                     </a>
+                    <ul v-if="issue.review_apps.length > 0" class="list-csv review-app-list">
+                        <li v-for="url in issue.review_apps">
+                            <a :href="url" target="_blank">
+                                <?= _('Testsystem') ?>
+                            </a>
+                        </li>
+                    </ul>
                 </td>
                 <td :class="{'filter-match': valueMatchesNeedle(issue.author)}">
                     {{ issue.author }}
@@ -123,12 +131,28 @@ $attributes = [
             <div class="sidebar-widget-header"><?= _('Filter') ?></div>
             <div class="sidebar-widget-content">
                 <label v-for="(abbr, label) in qmLabels" style="display: block;">
-                    {{ label }}
                     <select v-model="filters[abbr]" style="display: block;" class="sidebar-selectlist">
-                        <option :value="null"></option>
-                        <option>+</option>
-                        <option>?</option>
-                        <option>-</option>
+                        <option :value="null">{{ label }}</option>
+                        <option value="+">{{ label }} +</option>
+                        <option value="?">{{ label }} ?</option>
+                        <option value="-">{{ label }} -</option>
+                    </select>
+                </label>
+
+                <label style="display: block">
+                    <select v-model="filters.status" style="display: block;" class="sidebar-selectlist">
+                        <option :value="null"><?= _('Status') ?></option>
+                        <option value="open"><?= _('Status') ?>: <?= _('Offen') ?></option>
+                        <option value="closed"><?= _('Status') ?>: <?= _('Geschlossen') ?></option>
+                    </select>
+                </label>
+
+                <label style="display: block">
+                    <select v-model="filters.mr_status" style="display: block;" class="sidebar-selectlist">
+                        <option :value="null"><?= _('Merge Request') ?></option>
+                        <option value="none"><?= _('Merge Request') ?>: <?= _('Keiner') ?></option>
+                        <option value="pending"><?= _('Merge Request') ?>: <?= _('In Arbeit') ?></option>
+                        <option value="merged"><?= _('Merge Request') ?>: <?= _('Geschlossen') ?></option>
                     </select>
                 </label>
             </div>
diff --git a/views/merge/sidebar.php b/views/merge/sidebar.php
deleted file mode 100644
index d8de1a3..0000000
--- a/views/merge/sidebar.php
+++ /dev/null
@@ -1 +0,0 @@
-Zeitpunkt: <?= date('d.m.Y H:i:s', $time) ?>
diff --git a/views/mergerequests/sidebar.php b/views/mergerequests/sidebar.php
deleted file mode 100644
index d8de1a3..0000000
--- a/views/mergerequests/sidebar.php
+++ /dev/null
@@ -1 +0,0 @@
-Zeitpunkt: <?= date('d.m.Y H:i:s', $time) ?>
diff --git a/views/sidebar.php b/views/sidebar.php
new file mode 100644
index 0000000..eea26b3
--- /dev/null
+++ b/views/sidebar.php
@@ -0,0 +1,6 @@
+<?php
+/**
+ * @var int $time
+ */
+?>
+<?= strftime('%x %X', $time) ?>
-- 
GitLab