1<?php
2class GithubIssueBridge extends BridgeAbstract {
3
4	const MAINTAINER = 'Pierre Mazière';
5	const NAME = 'Github Issue';
6	const URI = 'https://github.com/';
7	const CACHE_TIMEOUT = 600; // 10min
8	const DESCRIPTION = 'Returns the issues or comments of an issue of a github project';
9
10	const PARAMETERS = array(
11		'global' => array(
12			'u' => array(
13				'name' => 'User name',
14				'required' => true
15			),
16			'p' => array(
17				'name' => 'Project name',
18				'required' => true
19			)
20		),
21		'Project Issues' => array(
22			'c' => array(
23				'name' => 'Show Issues Comments',
24				'type' => 'checkbox'
25			)
26		),
27		'Issue comments' => array(
28			'i' => array(
29				'name' => 'Issue number',
30				'type' => 'number',
31				'required' => true
32			)
33		)
34	);
35
36	// Allows generalization with GithubPullRequestBridge
37	const BRIDGE_OPTIONS = array(0 => 'Project Issues', 1 => 'Issue comments');
38	const URL_PATH = 'issues';
39	const SEARCH_QUERY_PATH = 'issues';
40	const SEARCH_QUERY = '?q=is%3Aissue+sort%3Aupdated-desc';
41
42	public function getName(){
43		$name = $this->getInput('u') . '/' . $this->getInput('p');
44		switch($this->queriedContext) {
45		case static::BRIDGE_OPTIONS[0]: // Project Issues
46			$prefix = static::NAME . 's for ';
47			if($this->getInput('c')) {
48				$prefix = static::NAME . 's comments for ';
49			}
50			$name = $prefix . $name;
51			break;
52		case static::BRIDGE_OPTIONS[1]: // Issue comments
53			$name = static::NAME . ' ' . $name . ' #' . $this->getInput('i');
54			break;
55		default: return parent::getName();
56		}
57		return $name;
58	}
59
60	public function getURI() {
61		if(null !== $this->getInput('u') && null !== $this->getInput('p')) {
62			$uri = static::URI . $this->getInput('u') . '/'
63				 . $this->getInput('p') . '/';
64			if($this->queriedContext === static::BRIDGE_OPTIONS[1]) {
65				$uri .= static::URL_PATH . '/' . $this->getInput('i');
66			} else {
67				$uri .= static::SEARCH_QUERY_PATH . static::SEARCH_QUERY;
68			}
69			return $uri;
70		}
71
72		return parent::getURI();
73	}
74
75	private function buildGitHubIssueCommentUri($issue_number, $comment_id) {
76		// https://github.com/<user>/<project>/issues/<issue-number>#<id>
77		return static::URI
78		. $this->getInput('u')
79		. '/'
80		. $this->getInput('p')
81		. '/' . static::URL_PATH . '/'
82		. $issue_number
83		. '#'
84		. $comment_id;
85	}
86
87	private function extractIssueEvent($issueNbr, $title, $comment) {
88
89		$uri = $this->buildGitHubIssueCommentUri($issueNbr, $comment->id);
90
91		$author = $comment->find('.author, .avatar', 0);
92		if ($author) {
93			$author = trim($author->href, '/');
94		} else {
95			$author = '';
96		}
97
98		$title .= ' / '
99			. trim(str_replace(
100					array('octicon','-'), array(''),
101					$comment->find('.octicon', 0)->getAttribute('class')
102			));
103
104		$time = $comment->find('relative-time', 0);
105		if ($time === null) {
106			return;
107		}
108
109		foreach($comment->find('.Details-content--hidden, .btn') as $el) {
110			$el->innertext = '';
111		}
112		$content = $comment->plaintext;
113
114		$item = array();
115		$item['author'] = $author;
116		$item['uri'] = $uri;
117		$item['title'] = html_entity_decode($title, ENT_QUOTES, 'UTF-8');
118		$item['timestamp'] = strtotime($time->getAttribute('datetime'));
119		$item['content'] = $content;
120		return $item;
121	}
122
123	private function extractIssueComment($issueNbr, $title, $comment) {
124		$uri = $this->buildGitHubIssueCommentUri($issueNbr, $comment->id);
125
126		$author = $comment->find('.author', 0)->plaintext;
127
128		$title .= ' / ' . trim(
129			$comment->find('.timeline-comment-header-text', 0)->plaintext
130		);
131
132		$time = $comment->find('relative-time', 0);
133		if ($time === null) {
134			return;
135		}
136
137		$content = $comment->find('.comment-body', 0)->innertext;
138
139		$item = array();
140		$item['author'] = $author;
141		$item['uri'] = $uri;
142		$item['title'] = html_entity_decode($title, ENT_QUOTES, 'UTF-8');
143		$item['timestamp'] = strtotime($time->getAttribute('datetime'));
144		$item['content'] = $content;
145		return $item;
146	}
147
148	private function extractIssueComments($issue) {
149		$items = array();
150		$title = $issue->find('.gh-header-title', 0)->plaintext;
151		$issueNbr = trim(
152			substr($issue->find('.gh-header-number', 0)->plaintext, 1)
153		);
154
155		$comments = $issue->find(
156			'.comment, .TimelineItem-badge'
157		);
158
159		foreach($comments as $comment) {
160			if ($comment->hasClass('comment')) {
161				$comment = $comment->parent;
162				$item = $this->extractIssueComment($issueNbr, $title, $comment);
163				if ($item !== null) {
164					$items[] = $item;
165				}
166				continue;
167			} else {
168				$comment = $comment->parent;
169				$item = $this->extractIssueEvent($issueNbr, $title, $comment);
170				if ($item !== null) {
171					$items[] = $item;
172				}
173			}
174
175		}
176		return $items;
177	}
178
179	public function collectData() {
180		$html = getSimpleHTMLDOM($this->getURI())
181			or returnServerError(
182				'No results for ' . static::NAME . ' ' . $this->getURI()
183			);
184
185		switch($this->queriedContext) {
186		case static::BRIDGE_OPTIONS[1]: // Issue comments
187			$this->items = $this->extractIssueComments($html);
188			break;
189		case static::BRIDGE_OPTIONS[0]: // Project Issues
190			foreach($html->find('.js-active-navigation-container .js-navigation-item') as $issue) {
191				$info = $issue->find('.opened-by', 0);
192
193				preg_match('/\/([0-9]+)$/', $issue->find('a', 0)->href, $match);
194				$issueNbr = $match[1];
195
196				$item = array();
197				$item['content'] = '';
198
199				if($this->getInput('c')) {
200					$uri = static::URI . $this->getInput('u')
201						 . '/' . $this->getInput('p') . '/' . static::URL_PATH . '/' . $issueNbr;
202					$issue = getSimpleHTMLDOMCached($uri, static::CACHE_TIMEOUT);
203					if($issue) {
204						$this->items = array_merge(
205							$this->items,
206							$this->extractIssueComments($issue)
207						);
208						continue;
209					}
210					$item['content'] = 'Can not extract comments from ' . $uri;
211				}
212
213				$item['author'] = $info->find('a', 0)->plaintext;
214				$item['timestamp'] = strtotime(
215					$info->find('relative-time', 0)->getAttribute('datetime')
216				);
217				$item['title'] = html_entity_decode(
218					$issue->find('.js-navigation-open', 0)->plaintext,
219					ENT_QUOTES,
220					'UTF-8'
221				);
222
223				$comment_count = 0;
224				if($span = $issue->find('a[aria-label*="comment"] span', 0)) {
225					$comment_count = $span->plaintext;
226				}
227
228				$item['content'] .= "\n" . 'Comments: ' . $comment_count;
229				$item['uri'] = self::URI
230							 . trim($issue->find('.js-navigation-open', 0)->getAttribute('href'), '/');
231				$this->items[] = $item;
232			}
233			break;
234		}
235
236		array_walk($this->items, function(&$item){
237			$item['content'] = preg_replace('/\s+/', ' ', $item['content']);
238			$item['content'] = str_replace(
239				'href="/',
240				'href="' . static::URI,
241				$item['content']
242			);
243			$item['content'] = str_replace(
244				'href="#',
245				'href="' . substr($item['uri'], 0, strpos($item['uri'], '#') + 1),
246				$item['content']
247			);
248			$item['title'] = preg_replace('/\s+/', ' ', $item['title']);
249		});
250	}
251
252	public function detectParameters($url) {
253
254		if(filter_var($url, FILTER_VALIDATE_URL, FILTER_FLAG_PATH_REQUIRED) === false
255		|| strpos($url, self::URI) !== 0) {
256			return null;
257		}
258
259		$url_components = parse_url($url);
260		$path_segments = array_values(array_filter(explode('/', $url_components['path'])));
261
262		switch(count($path_segments)) {
263			case 2: { // Project issues
264				list($user, $project) = $path_segments;
265				$show_comments = 'off';
266			} break;
267			case 3: { // Project issues with issue comments
268				if($path_segments[2] !== static::URL_PATH) {
269					return null;
270				}
271				list($user, $project) = $path_segments;
272				$show_comments = 'on';
273			} break;
274			case 4: { // Issue comments
275				list($user, $project, /* issues */, $issue) = $path_segments;
276			} break;
277			default: {
278				return null;
279			}
280		}
281
282		return array(
283			'u' => $user,
284			'p' => $project,
285			'c' => isset($show_comments) ? $show_comments : null,
286			'i' => isset($issue) ? $issue : null,
287		);
288
289	}
290}
291