diff --git a/bridges/GitlabIssueBridge.php b/bridges/GitlabIssueBridge.php
new file mode 100644
index 00000000..caf94568
--- /dev/null
+++ b/bridges/GitlabIssueBridge.php
@@ -0,0 +1,209 @@
+ array(
+ 'h' => array(
+ 'name' => 'Gitlab instance host name',
+ 'exampleValue' => 'gitlab.com',
+ 'defaultValue' => 'gitlab.com',
+ 'required' => true
+ ),
+ 'u' => array(
+ 'name' => 'User/Organization name',
+ 'exampleValue' => 'fdroid',
+ 'required' => true
+ ),
+ 'p' => array(
+ 'name' => 'Project name',
+ 'exampleValue' => 'fdroidclient',
+ 'required' => true
+ )
+
+ ),
+ 'Issue comments' => array(
+ 'i' => array(
+ 'name' => 'Issue number',
+ 'type' => 'number',
+ 'exampleValue' => '2099',
+ 'required' => true
+ )
+ ),
+ 'Merge Request comments' => array(
+ 'i' => array(
+ 'name' => 'Merge Request number',
+ 'type' => 'number',
+ 'exampleValue' => '2099',
+ 'required' => true
+ )
+ )
+ );
+
+ public function getName(){
+ $name = $this->getInput('h') . '/' . $this->getInput('u') . '/' . $this->getInput('p');
+ switch ($this->queriedContext) {
+ case 'Issue comments':
+ $name .= ' Issue #' . $this->getInput('i');
+ break;
+ case 'Merge Request comments':
+ $name .= ' MR !' . $this->getInput('i');
+ break;
+ default:
+ return parent::getName();
+ }
+ return $name;
+ }
+
+ private function getProjectURI() {
+ $host = $this->getInput('h') ?? 'gitlab.com';
+ return 'https://' . $host . '/' . $this->getInput('u') . '/'
+ . $this->getInput('p') . '/';
+ }
+
+ public function getURI() {
+ $uri = $this->getProjectURI();
+ switch ($this->queriedContext) {
+ case 'Issue comments':
+ $uri .= '-/issues';
+ break;
+ case 'Merge Request comments':
+ $uri .= '-/merge_requests';
+ break;
+ default:
+ return $uri;
+ }
+ $uri .= '/' . $this->getInput('i');
+ return $uri;
+ }
+
+ public function getIcon() {
+ return 'https://' . $this->getInput('h') . '/favicon.ico';
+ }
+
+ public function collectData() {
+ switch ($this->queriedContext) {
+ case 'Issue comments':
+ $this->items[] = $this->parseIssueDescription();
+ break;
+ case 'Merge Request comments':
+ $this->items[] = $this->parseMRDescription();
+ break;
+ default:
+ break;
+ }
+
+ /* parse issue/MR comments */
+ $comments_uri = $this->getURI() . '/discussions.json';
+ $comments = getContents($comments_uri);
+ $comments = json_decode($comments, false);
+
+ foreach ($comments as $value) {
+ foreach ($value->notes as $comment) {
+ $item = array();
+ $item['uri'] = $comment->noteable_note_url;
+ $item['uid'] = $item['uri'];
+
+ // TODO fix invalid timestamps (fdroid bot)
+ $item['timestamp'] = $comment->created_at ?? $comment->updated_at ?? $comment->last_edited_at;
+ $author = $comment->author ?? $comment->last_edited_by;
+ $item['author'] = '
' . $author->name . ' @' . $author->username . '';
+
+ $content = '';
+ if ($comment->system) {
+ $content = $comment->note_html;
+ if ($comment->type === 'StateNote') {
+ $content .= ' the issue';
+ } elseif ($comment->type === null) {
+ // e.g. "added 900 commits\n800 from master\n175h4d - commit message\n..."
+ $content = str_get_html($comment->note_html)->find('p', 0);
+ }
+ } else {
+ // no switch-case to do strict comparison
+ if ($comment->type === null || $comment->type === 'DiscussionNote') {
+ $content = 'commented';
+ } elseif ($comment->type === 'DiffNote') {
+ $content = 'commented on a thread';
+ } else {
+ $content = $comment->note_html;
+ }
+ }
+ $item['title'] = $author->name . " $content";
+
+ $content = $this->fixImgSrc($comment->note_html);
+ $item['content'] = defaultLinkTo($content, 'https://' . $this->getInput('h') . '/');
+
+ $this->items[] = $item;
+ }
+ }
+ }
+
+ private function parseIssueDescription() {
+ $description_uri = $this->getURI() . '.json';
+ $description = getContents($description_uri);
+ $description = json_decode($description, false);
+ $description_html = getSimpleHtmlDomCached($this->getURI());
+
+ $item = array();
+ $item['uri'] = $this->getURI();
+ $item['uid'] = $item['uri'];
+
+ $item['timestamp'] = $description->created_at ?? $description->updated_at;
+
+ $item['author'] = $this->parseAuthor($description_html);
+
+ $item['title'] = $description->title;
+ $item['content'] = markdownToHtml($description->description);
+
+ return $item;
+ }
+
+ private function fixImgSrc($html) {
+ if (is_string($html)) {
+ $html = str_get_html($html);
+ }
+
+ foreach ($html->find('img') as $img) {
+ $img->src = $img->getAttribute('data-src');
+ }
+ return $html;
+ }
+
+ private function parseAuthor($description_html) {
+ $description_html = $this->fixImgSrc($description_html);
+
+ $authors = $description_html->find('.issuable-meta a.author-link, .merge-request a.author-link');
+ $editors = $description_html->find('.edited-text a.author-link');
+ $author_str = implode(' ', $authors);
+ if ($editors) {
+ $author_str .= ', ' . implode(' ', $editors);
+ }
+ return defaultLinkTo($author_str, 'https://' . $this->getInput('h') . '/');
+ }
+
+ private function parseMRDescription() {
+ $description_uri = $this->getURI() . '/cached_widget.json';
+ $description = getContents($description_uri);
+ $description = json_decode($description, false);
+ $description_html = getSimpleHtmlDomCached($this->getURI());
+
+ $item = array();
+ $item['uri'] = $this->getURI();
+ $item['uid'] = $item['uri'];
+
+ $item['timestamp'] = $description_html->find('.merge-request-details time', 0)->datetime;
+
+ $item['author'] = $this->parseAuthor($description_html);
+
+ $item['title'] = 'Merge Request ' . $description->title;
+ $item['content'] = markdownToHtml($description->description);
+
+ return $item;
+ }
+}