From 27d3fcc254f1d274a4adce94a09ac1f3c537870e Mon Sep 17 00:00:00 2001 From: Gojkira <94493888+Gojkira@users.noreply.github.com> Date: Thu, 25 Jun 2026 09:59:30 +0000 Subject: [PATCH 1/2] Update GithubIssueBridge.php --- bridges/GithubIssueBridge.php | 334 ++++++++++++++++++---------------- 1 file changed, 175 insertions(+), 159 deletions(-) diff --git a/bridges/GithubIssueBridge.php b/bridges/GithubIssueBridge.php index 6684017cbdd..be928142bf8 100644 --- a/bridges/GithubIssueBridge.php +++ b/bridges/GithubIssueBridge.php @@ -5,6 +5,7 @@ class GithubIssueBridge extends BridgeAbstract const MAINTAINER = 'Pierre Mazière'; const NAME = 'Github Issue'; const URI = 'https://github.com/'; + const API_URI = 'https://api.github.com/'; const CACHE_TIMEOUT = 600; // 10m const DESCRIPTION = 'Returns the issues or comments of an issue of a github project'; @@ -45,7 +46,8 @@ class GithubIssueBridge extends BridgeAbstract // Allows generalization with GithubPullRequestBridge const BRIDGE_OPTIONS = [0 => 'Project Issues', 1 => 'Issue comments']; const URL_PATH = 'issues'; - const SEARCH_QUERY_PATH = 'issues'; + // Used to restrict the GitHub search api to issues vs pulls (overridden by GithubPullRequestBridge) + const SEARCH_TYPE_QUALIFIER = 'is:issue'; public function getName() { @@ -75,7 +77,7 @@ public function getURI() if ($this->queriedContext === static::BRIDGE_OPTIONS[1]) { $uri .= static::URL_PATH . '/' . $this->getInput('i'); } else { - $uri .= static::SEARCH_QUERY_PATH . '?q=' . urlencode($this->getInput('q')); + $uri .= static::URL_PATH; } return $uri; } @@ -83,207 +85,221 @@ public function getURI() return parent::getURI(); } - private function buildGitHubIssueCommentUri($issue_number, $comment_id) + private function apiHeaders() + { + return [ + 'Accept: application/vnd.github+json', + 'User-Agent: RSS-Bridge', + 'X-GitHub-Api-Version: 2022-11-28', + ]; + } + + private function apiRequest($url) + { + $json = getContents($url, $this->apiHeaders()); + $data = json_decode($json, true); + if (json_last_error() !== JSON_ERROR_NONE) { + returnServerError('Unable to parse GitHub API response for ' . $url); + } + if (isset($data['message']) && !isset($data['html_url']) && array_key_exists('documentation_url', $data)) { + // Looks like an API error payload, e.g. rate limiting or not found + returnServerError('GitHub API error for ' . $url . ': ' . $data['message']); + } + return $data; + } + + private function buildGitHubIssueUri($issue_number) { - // https://github.com///issues/# return static::URI - . $this->getInput('u') - . '/' - . $this->getInput('p') - . '/' . static::URL_PATH . '/' - . $issue_number - . '#' - . $comment_id; + . $this->getInput('u') . '/' . $this->getInput('p') + . '/' . static::URL_PATH . '/' . $issue_number; } - private function extractIssueEvent($issueNbr, $title, $comment) + private function buildGitHubIssueCommentUri($issue_number, $comment_id) { - $uri = $this->buildGitHubIssueCommentUri($issueNbr, $comment->id); + return $this->buildGitHubIssueUri($issue_number) . '#issuecomment-' . $comment_id; + } - $author = $comment->find('.author, .avatar', 0); - if ($author) { - $author = trim($author->href, '/'); - } else { - $author = ''; + private function markdownToHtml($text) + { + if ($text === null || $text === '') { + return ''; } + // GitHub's API returns raw markdown in the body. We don't have a markdown + // renderer available, so at minimum make it safe and readable as plain text. + return nl2br(htmlspecialchars($text, ENT_QUOTES, 'UTF-8')); + } - $title .= ' / ' - . trim(str_replace( - ['octicon','-'], - [''], - $comment->find('.octicon', 0)->getAttribute('class') - )); + /** + * Parses a GitHub search-style query string (e.g. "is:issue is:open sort:updated-desc") + * into a q= value usable by the GitHub Search API plus separate sort/order params. + */ + private function parseSearchQuery($query) + { + $sort = null; + $order = null; + + $query = preg_replace_callback( + '/\bsort:([a-zA-Z\-]+)\b/', + function ($m) use (&$sort, &$order) { + $value = $m[1]; + if (str_ends_with($value, '-desc')) { + $sort = substr($value, 0, -5); + $order = 'desc'; + } elseif (str_ends_with($value, '-asc')) { + $sort = substr($value, 0, -4); + $order = 'asc'; + } else { + $sort = $value; + } + return ''; + }, + $query + ); + + return [ + 'q' => trim(preg_replace('/\s+/', ' ', $query)), + 'sort' => $sort, + 'order' => $order, + ]; + } - $time = $comment->find('relative-time', 0); - if ($time === null) { - return; + /** + * Builds a short, single-line snippet of a markdown body, suitable for use in a title. + * Strips markdown blockquote lines (lines starting with '>') first, since these are + * usually quoting someone else's earlier comment rather than the commenter's own words. + */ + private function makeSnippet($text, $maxLength = 80) + { + if ($text === null || $text === '') { + return ''; } - foreach ($comment->find('.Details-content--hidden, .btn') as $el) { - $el->innertext = ''; + $lines = preg_split('/\r\n|\r|\n/', $text); + $lines = array_filter($lines, function ($line) { + return !preg_match('/^\s*>/', $line); + }); + $text = implode(' ', $lines); + + $snippet = preg_replace('/\s+/', ' ', trim($text)); + if ($snippet === '') { + return ''; } - $content = $comment->plaintext; + if (mb_strlen($snippet) > $maxLength) { + $snippet = mb_substr($snippet, 0, $maxLength) . '…'; + } + return $snippet; + } + private function buildIssueItem($issue) + { $item = []; - $item['author'] = $author; - $item['uri'] = $uri; - $item['title'] = html_entity_decode($title, ENT_QUOTES, 'UTF-8'); - $item['timestamp'] = strtotime($time->getAttribute('datetime')); + $item['uri'] = $issue['html_url']; + $item['title'] = $issue['title']; + $item['author'] = $issue['user']['login'] ?? ''; + // Use creation time here: this represents the issue/PR as first filed. + // (updated_at reflects the *last* change to the issue, e.g. a recent + // comment or label edit, and would otherwise sort newly-opened issues + // as if they were old, or vice versa.) + $item['timestamp'] = strtotime($issue['created_at']); + $labels = array_map(function ($label) { + return is_array($label) ? ($label['name'] ?? '') : $label; + }, $issue['labels'] ?? []); + $content = $this->markdownToHtml($issue['body'] ?? ''); + if ($labels) { + $content = '

Labels: ' . implode(', ', $labels) . '

' . $content; + } $item['content'] = $content; + $item['uid'] = (string)$issue['id']; return $item; } - private function extractIssueComment($issueNbr, $title, $comment) + private function buildCommentItem($issueNbr, $title, $comment) { - $uri = $this->buildGitHubIssueCommentUri($issueNbr, $comment->id); - - $authorDom = $comment->find('.author', 0); - $author = $authorDom->plaintext ?? null; - - $header = $comment->find('.timeline-comment-header > h3', 0); - $title .= ' / ' . ($header ? $header->plaintext : 'Activity'); - - $time = $comment->find('relative-time', 0); - if ($time === null) { - return; - } - - $content = $comment->find('.comment-body', 0)->innertext; - $item = []; - $item['author'] = $author; - $item['uri'] = $uri; - $item['title'] = html_entity_decode($title, ENT_QUOTES, 'UTF-8'); - $item['timestamp'] = strtotime($time->getAttribute('datetime')); - $item['content'] = $content; + $item['uri'] = $this->buildGitHubIssueCommentUri($issueNbr, $comment['id']); + $snippet = $this->makeSnippet($comment['body'] ?? ''); + $item['title'] = $snippet !== '' ? $snippet : ($title . ' / Comment'); + $item['author'] = $comment['user']['login'] ?? ''; + // Comments are immutable for our purposes here: created_at is the right + // anchor (an edited comment shouldn't jump the feed to "now"). + $item['timestamp'] = strtotime($comment['created_at']); + $item['content'] = $this->markdownToHtml($comment['body'] ?? ''); + $item['uid'] = (string)$comment['id']; return $item; } - private function extractIssueComments($issue) + private function collectIssueComments($issueNbr) { $items = []; - $titleElem = $issue->find('.gh-header-title', 0); - $title = $titleElem !== null ? $titleElem->plaintext : ''; - - $numberElem = $issue->find('.gh-header-number', 0); - if ($numberElem !== null) { - $issueNbr = trim( - substr($numberElem->plaintext, 1) - ); - } else { - $issueNbr = ''; - } - - $comments = $issue->find( - '.comment, .TimelineItem-badge' - ); - - foreach ($comments as $comment) { - if ($comment->hasClass('comment')) { - $comment = $comment->parent; - $item = $this->extractIssueComment($issueNbr, $title, $comment); - if ($item !== null) { - $items[] = $item; - } - continue; - } else { - $comment = $comment->parent; - $item = $this->extractIssueEvent($issueNbr, $title, $comment); - if ($item !== null) { - $items[] = $item; - } + $issueUrl = static::API_URI . 'repos/' . $this->getInput('u') . '/' . $this->getInput('p') + . '/issues/' . $issueNbr; + $issue = $this->apiRequest($issueUrl); + + $title = $issue['title'] ?? ('#' . $issueNbr); + + // The issue body itself is treated as the first item (mirrors old behaviour + // of including the opening post in the timeline) + $opening = $this->buildIssueItem($issue); + $openingSnippet = $this->makeSnippet($issue['body'] ?? ''); + $opening['title'] = $title . ' / Opened' . ($openingSnippet !== '' ? ': ' . $openingSnippet : ''); + $items[] = $opening; + + $commentsUrl = $issueUrl . '/comments?per_page=100'; + $page = 1; + do { + $pagedUrl = $commentsUrl . '&page=' . $page; + $comments = $this->apiRequest($pagedUrl); + if (!is_array($comments) || count($comments) === 0) { + break; } - } + foreach ($comments as $comment) { + $items[] = $this->buildCommentItem($issueNbr, $title, $comment); + } + $page++; + } while (count($comments) === 100); + return $items; } public function collectData() { - $url = $this->getURI(); - $html = getSimpleHTMLDOM($url); - switch ($this->queriedContext) { case static::BRIDGE_OPTIONS[1]: // Issue comments - $this->items = $this->extractIssueComments($html); + $this->items = $this->collectIssueComments($this->getInput('i')); break; + case static::BRIDGE_OPTIONS[0]: // Project Issues - // PRs - $issues = $html->find('.js-active-navigation-container .js-navigation-item'); - if (!$issues) { - // Issues - $issues = $html->find('.IssueRow-module__row--XmR1f'); - } + $parsed = $this->parseSearchQuery($this->getInput('q')); + $repoQualifier = 'repo:' . $this->getInput('u') . '/' . $this->getInput('p'); + $q = trim($parsed['q'] . ' ' . static::SEARCH_TYPE_QUALIFIER . ' ' . $repoQualifier); - foreach ($issues as $issue) { - preg_match('/\/([0-9]+)$/', $issue->find('a', 0)->href, $match); - $issueNbr = $match[1]; + $params = ['q' => $q, 'per_page' => '50']; + if ($parsed['sort']) { + $params['sort'] = $parsed['sort']; + } + if ($parsed['order']) { + $params['order'] = $parsed['order']; + } - $item = []; - $item['content'] = ''; + $searchUrl = static::API_URI . 'search/issues?' . http_build_query($params); + $result = $this->apiRequest($searchUrl); + $issues = $result['items'] ?? []; + foreach ($issues as $issue) { if ($this->getInput('c')) { - $uri = static::URI . $this->getInput('u') - . '/' . $this->getInput('p') . '/' . static::URL_PATH . '/' . $issueNbr; - - $issue = getSimpleHTMLDOMCached($uri, static::CACHE_TIMEOUT); - if ($issue) { - $this->items = array_merge( - $this->items, - $this->extractIssueComments($issue) - ); - continue; - } - $item['content'] = 'Can not extract comments from ' . $uri; - } - - $item['author'] = $issue->find('a', 1)->plaintext; - - $time = $issue->find('relative-time', 0); - $datetime = $time->getAttribute('datetime'); - if ($datetime) { - $item['timestamp'] = strtotime($datetime); - } - - $item['title'] = ''; - - # Works for PRs - $title = $issue->find('a.Link--primary', 0); - if ($title) { - $item['title'] = html_entity_decode($title->plaintext, ENT_QUOTES, 'UTF-8'); + $issueNbr = $issue['number']; + $this->items = array_merge( + $this->items, + $this->collectIssueComments($issueNbr) + ); + continue; } - - $title2 = $issue->find('h3 a', 0); - if ($title2) { - $item['title'] = html_entity_decode($title2->plaintext, ENT_QUOTES, 'UTF-8'); - } - //$comment_count = 0; - //if ($span = $issue->find('a[aria-label*="comment"] span', 0)) { - // $comment_count = $span->plaintext; - //} - - //$item['content'] .= "\n" . 'Comments: ' . $comment_count; - $item['uri'] = self::URI - . trim($issue->find('a', 0)->getAttribute('href'), '/'); - $this->items[] = $item; + $this->items[] = $this->buildIssueItem($issue); } break; } - - array_walk($this->items, function (&$item) { - $item['content'] = preg_replace('/\s+/', ' ', $item['content']); - $item['content'] = str_replace( - 'href="/', - 'href="' . static::URI, - $item['content'] - ); - $item['content'] = str_replace( - 'href="#', - 'href="' . substr($item['uri'], 0, strpos($item['uri'], '#') + 1), - $item['content'] - ); - $item['title'] = preg_replace('/\s+/', ' ', $item['title']); - }); } public function detectParameters($url) From eac928107bdff62e88239a0e92cf0ba7af10ada1 Mon Sep 17 00:00:00 2001 From: Gojkira <94493888+Gojkira@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:29:07 +0000 Subject: [PATCH 2/2] Update GithubIssueBridge.php Added option to add events to the feed. --- bridges/GithubIssueBridge.php | 162 +++++++++++++++++++++++++++++++++- 1 file changed, 160 insertions(+), 2 deletions(-) diff --git a/bridges/GithubIssueBridge.php b/bridges/GithubIssueBridge.php index be928142bf8..439e9aaaebf 100644 --- a/bridges/GithubIssueBridge.php +++ b/bridges/GithubIssueBridge.php @@ -27,6 +27,10 @@ class GithubIssueBridge extends BridgeAbstract 'name' => 'Show Issues Comments', 'type' => 'checkbox' ], + 'e' => [ + 'name' => 'Show Events', + 'type' => 'checkbox' + ], 'q' => [ 'name' => 'Search Query', 'defaultValue' => 'is:issue is:open sort:updated-desc', @@ -34,12 +38,16 @@ class GithubIssueBridge extends BridgeAbstract ] ], 'Issue comments' => [ + 'e' => [ + 'name' => 'Show Events', + 'type' => 'checkbox' + ], 'i' => [ 'name' => 'Issue number', 'type' => 'number', 'exampleValue' => '2099', 'required' => true - ] + ], ] ]; @@ -229,10 +237,156 @@ private function buildCommentItem($issueNbr, $title, $comment) return $item; } - private function collectIssueComments($issueNbr) + private function buildGitHubEventUri($issue_number, $event) + { + if (!empty($event['id'])) { + return $this->buildGitHubIssueUri($issue_number) . '#event-' . $event['id']; + } + return $this->buildGitHubIssueUri($issue_number); + } + + /** + * Turns a single GitHub issue-timeline event into a human-readable title. + * Returns null for event types we don't render (e.g. 'commented', which is + * already covered by the comments endpoint, or anything unrecognized). + */ + private function describeEvent($event) + { + $type = $event['event'] ?? ''; + switch ($type) { + case 'closed': + return 'Closed'; + case 'reopened': + return 'Reopened'; + case 'labeled': + return 'Label added: ' . ($event['label']['name'] ?? '?'); + case 'unlabeled': + return 'Label removed: ' . ($event['label']['name'] ?? '?'); + case 'renamed': + $from = $event['rename']['from'] ?? '?'; + $to = $event['rename']['to'] ?? '?'; + return 'Renamed: "' . $from . '" → "' . $to . '"'; + case 'assigned': + return 'Assigned to ' . ($event['assignee']['login'] ?? '?'); + case 'unassigned': + return 'Unassigned from ' . ($event['assignee']['login'] ?? '?'); + case 'milestoned': + return 'Milestone added: ' . ($event['milestone']['title'] ?? '?'); + case 'demilestoned': + return 'Milestone removed: ' . ($event['milestone']['title'] ?? '?'); + case 'locked': + return 'Locked'; + case 'unlocked': + return 'Unlocked'; + case 'pinned': + return 'Pinned'; + case 'unpinned': + return 'Unpinned'; + case 'transferred': + return 'Transferred'; + case 'merged': + return 'Merged'; + case 'review_requested': + $reviewer = $event['requested_reviewer']['login'] ?? ($event['requested_team']['name'] ?? '?'); + return 'Review requested from ' . $reviewer; + case 'review_request_removed': + $reviewer = $event['requested_reviewer']['login'] ?? ($event['requested_team']['name'] ?? '?'); + return 'Review request removed for ' . $reviewer; + case 'reviewed': + return 'Reviewed (' . ($event['state'] ?? '?') . ')'; + case 'cross-referenced': + $source = $event['source']['issue']['html_url'] ?? null; + $number = $event['source']['issue']['number'] ?? '?'; + return 'Mentioned in #' . $number; + case 'referenced': + return 'Referenced in a commit'; + case 'connected': + return 'Linked to this issue'; + case 'disconnected': + return 'Unlinked from this issue'; + case 'convert_to_draft': + return 'Converted to draft'; + case 'ready_for_review': + return 'Marked ready for review'; + case 'head_ref_force_pushed': + return 'Branch force-pushed'; + case 'head_ref_deleted': + return 'Branch deleted'; + case 'head_ref_restored': + return 'Branch restored'; + default: + return null; + } + } + + private function buildEventItem($issueNbr, $event) + { + $description = $this->describeEvent($event); + if ($description === null) { + return null; + } + + // cross-referenced events still belong on this issue's own page; the + // actor who made the mention lives on the *source* issue/PR though. + $uri = $this->buildGitHubEventUri($issueNbr, $event); + if (($event['event'] ?? '') === 'cross-referenced') { + $author = $event['source']['issue']['user']['login'] ?? ''; + } else { + $author = $event['actor']['login'] ?? ''; + } + + $timestamp = $event['created_at'] ?? null; + if ($timestamp === null) { + return null; + } + + $item = []; + $item['uri'] = $uri; + $item['title'] = $description; + $item['author'] = $author; + $item['timestamp'] = strtotime($timestamp); + $item['content'] = $description; + $item['uid'] = (string)($event['id'] ?? ($event['node_id'] ?? uniqid())); + return $item; + } + + private function collectIssueEvents($issueNbr) { $items = []; + $timelineUrl = static::API_URI . 'repos/' . $this->getInput('u') . '/' . $this->getInput('p') + . '/issues/' . $issueNbr . '/timeline?per_page=100'; + $headers = array_merge($this->apiHeaders(), [ + // The Timeline API historically required this media type; harmless to + // include even where it's no longer strictly necessary. + 'Accept: application/vnd.github.mockingbird-preview+json', + ]); + + $page = 1; + do { + $json = getContents($timelineUrl . '&page=' . $page, $headers); + $events = json_decode($json, true); + if (!is_array($events) || count($events) === 0) { + break; + } + foreach ($events as $event) { + // 'commented' events are already covered by collectIssueComments(); + // skip to avoid duplicate items. + if (($event['event'] ?? '') === 'commented') { + continue; + } + $item = $this->buildEventItem($issueNbr, $event); + if ($item !== null) { + $items[] = $item; + } + } + $page++; + } while (count($events) === 100); + return $items; + } + private function collectIssueComments($issueNbr) + { + $items = []; $issueUrl = static::API_URI . 'repos/' . $this->getInput('u') . '/' . $this->getInput('p') . '/issues/' . $issueNbr; $issue = $this->apiRequest($issueUrl); @@ -260,6 +414,10 @@ private function collectIssueComments($issueNbr) $page++; } while (count($comments) === 100); + if ($this->getInput('e')) { + $items = array_merge($items, $this->collectIssueEvents($issueNbr)); + } + return $items; }