diff --git a/TracToGitlabPlugin.php b/TracToGitlabPlugin.php index b605157c3d67e74d94b8baabaaa0b1702ca4e4fd..3bdb8f6f25c7b8d9172c1f82728820b8306b50d6 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 0000000000000000000000000000000000000000..3bc65d9d03756dfbd3018cb7bf67b362b696dd37 --- /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 32b3bb6ce3fc1df33927f33fe56d612e3c85c3af..c09f62e932e8094eb7099ce50baf5201469e9489 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 42a679f149d4cc6d48c45220fd37ff08898ba7da..1580855625a1d4ead77da2b1c25cb00a9d679374 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 99fbeae2ff4556509dc27c447a1ef252770cdeba..8ec86c7a8f35f8507d0e70f21626c1c3fbe1de84 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 9fbc2b2391c1f020df03a7ae7a4332865bbcfb54..bba2d97a81ab72924d7b7cf6c2c063683c7604a2 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 a18a0321908e25a5ab736cac2eb44fde6645f22c..78f140817e4ac3f3eeb6b44bf9689b5761e233b0 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 0000000000000000000000000000000000000000..68c994d9ad67a842cdd4b3a08bde06a0352ed174 --- /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 e506596b8fc8118fa277607a010747bc976884c3..c75a1ea365548e793183ef0bc6264a0f6497b33f 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 77740986a80d9b2c8f540c73347477c4193dc672..0000000000000000000000000000000000000000 --- 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 36827e3d8a3bfb3977fca1d19655273db96a94c2..8a9a52cac7f3f64a767432198e7cc0eff968f36d 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 9ad0002915b53ea5fcd4b61205aa05ba8d999463..d2b6046a4da627882b0cd6573e00859b26da34d6 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 61de29395baf248c846d89138b0ec7f6307880c3..66985de9528af5d99d919b1dfaf5f06cda8ad901 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 69f47ec37dd53d26a5a4f95b5a864a2a9c5f9c27..882aee0a07fe2f9e5624e79588642a126190df15 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 ec4a6bc846c5ad73410a1c14d5b8679359237c34..b56d483f1de0f4f1a990303222e42c3e4bc40352 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 0000000000000000000000000000000000000000..292d9bdf7152feef890e87254da27d6a52232a39 --- /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 0000000000000000000000000000000000000000..9cec7122d3310ca8fce59e1fed58036fc38cbb2d --- /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 09a987d4c10cdd0bfa42c92d5c3381bf882cf05f..1f2214e51b0675d3b9dab8f4c1e79d6ab825afd1 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 bfc6d992973179f4e56d3dc1c336a4e3609202c2..8e2d62730ce4f2c5c24e64a120322dfde56405cb 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 0000000000000000000000000000000000000000..1c47484ac123474354e6b47266637ebed29380dc --- /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 0000000000000000000000000000000000000000..b1ae5fd0275c35fedc215f1690a42eb5f1e4c27b --- /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 0000000000000000000000000000000000000000..c0cf214a8a26866dc5a91f8aa95d0721aa2dd954 --- /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 0000000000000000000000000000000000000000..a6875a1cf85846dd59cfc677af6fbdf8a76ab8a9 --- /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 0000000000000000000000000000000000000000..75fa9b4335631867aee12421c8e677028f080c2c --- /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 e4ac7647a486b7f5a93a090159c9bd70a9bae6b8..a1275d71d821e487a47d447206f0b3590c0a0fde 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 00f63bdeb514f44081eaca502749b1320ea053d2..c08f62a2b9b956d86488b90b27405432e119b0fc 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 0000000000000000000000000000000000000000..9353f03297bc4e020e16fbbc7580ef243da5edc5 --- /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 0000000000000000000000000000000000000000..c28a047a3d810545ed321b17b7ba29580e31ce2d --- /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 0000000000000000000000000000000000000000..8a37218c0737638eb6f5df0a59ef9b10eda01ac0 --- /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 0000000000000000000000000000000000000000..b96dd428025717492bab105c1f16893e2b905e5c --- /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 4bdf2191ca6759deb00f59ce20c002116245fe42..8badf746cdd57a75498c331318225d68078bfbfa 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 0000000000000000000000000000000000000000..f2d8692f759bf3a8c6e499b5bb15fbf8912bb5fe --- /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 0000000000000000000000000000000000000000..85c578e1043bffbcbf9860684b96f4d7b0fa8242 --- /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 0000000000000000000000000000000000000000..ac7a7bb7933e4927c487f495d671b91d5afe90e4 --- /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 0000000000000000000000000000000000000000..7d1e41e1517cb848a27d0f09b615ba1c66585637 --- /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 0000000000000000000000000000000000000000..3de29f305176b5ac2e7d0a694f6d12cf80fcb5f0 --- /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 0000000000000000000000000000000000000000..aae8f099e554bb893c0fe9a82130f728ed13dab5 --- /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 0000000000000000000000000000000000000000..739a8f72778b550e0e6998fd5ca1a87eae464451 --- /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 60267d88b95e88f4d0e739894eb9e96aada4918b..f9a5985911db403980fae20f8220fb6ddabce2d9 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 d8de1a3a16f952649f3a1edc52f86726ff438cb6..0000000000000000000000000000000000000000 --- 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 d8de1a3a16f952649f3a1edc52f86726ff438cb6..0000000000000000000000000000000000000000 --- 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 0000000000000000000000000000000000000000..eea26b3b19e01fea02472d136abedb0a96301f64 --- /dev/null +++ b/views/sidebar.php @@ -0,0 +1,6 @@ +<?php +/** + * @var int $time + */ +?> +<?= strftime('%x %X', $time) ?>