1
0
Fork 0

Merge pull request #10 from Findus23/renew

modernize
This commit is contained in:
Matthieu Aubry 2017-09-25 10:04:45 +13:00 committed by GitHub
commit eb3fc1c120
22 changed files with 3711 additions and 902 deletions

5
.gitignore vendored
View file

@ -6,4 +6,7 @@ src/tmp/github_api_cache/*
!src/tmp/templates_cache/.gitkeep
!src/tmp/github_api_cache/.gitkeep
src/vendor
src/composer.phar
src/composer.phar
src/config/config.php
src/node_modules/
src/public/css/style.*

View file

@ -14,8 +14,11 @@ Try it, it's easy to setup! No database needed
* `cd github-issues-mirror/src`
* `curl -s https://getcomposer.org/installer | php`
* `php composer.phar install`
* `npm install`
* `npm run compileCSS`
* `cp config/config.example.php config/config.php`
Make sure to point your vhost to `src/public`. The `src/tmp` directory has to be writable.
Make sure to point your vhost to `src/public`. The `src/tmp` and the `src/data` directories have to be writable.
### Import issues from GitHub
@ -25,7 +28,7 @@ You may want to setup a cronjob to import the issues regularly. The first time y
## Configuration
See [src/config/config.php](src/config/config.php)
See [src/config/config.php](src/config/config.example.php)
## Data structure

View file

@ -1,17 +1,18 @@
{
"require": {
"slim/slim": "2.*",
"twig/twig": "1.15.*",
"slim/views": "0.1.*",
"slim/extras": "2.*",
"knplabs/github-api": "1.2.*",
"michelf/php-markdown": "1.4.*",
"ezyang/htmlpurifier": "4.6.*",
"phpmailer/phpmailer": "5.2.*"
"slim/slim": "^3.0",
"ezyang/htmlpurifier": "^4.9",
"phpmailer/phpmailer": "^6.0",
"erusev/parsedown": "^1.6",
"slim/twig-view": "^2.2",
"knplabs/github-api": "^2.5",
"php-http/guzzle6-adapter": "^1.1",
"cache/filesystem-adapter": "^1.0",
"monolog/monolog": "^1.23"
},
"autoload":{
"psr-0":{
"": ""
}
}
}
}

1996
src/composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -32,3 +32,15 @@ define('NUMBER_OF_ISSUES_PER_PAGE', 100);
* error messages will be displayed if enabled.
*/
define('DEBUG_ENABLED', false);
/**
* Set list of file extensions that should be disallowed in links
* see https://github.com/piwik/github-issues-mirror/issues/5
*/
define('FORBIDDEN_EXTENSIONS', ['swf', 'js', 'html', 'htm']);
/**
* If you want to enable piwik tracking enter the URL to your piwik instance and the ID of the website here
*/
define('PIWIK_URL', false);
define('PIWIK_ID', false);

View file

@ -8,29 +8,40 @@
namespace helpers;
use Cache\Adapter\Filesystem\FilesystemCachePool;
use Github\Client;
use Github\HttpClient\CachedHttpClient;
use Github\ResultPager;
use League\Flysystem\Adapter\Local;
use League\Flysystem\Filesystem;
use Monolog\Logger;
class GithubImporter {
private $client;
public function __construct(Client $client)
private $logger;
public function __construct(Client $client, Logger $logger)
{
$this->client = $client;
$this->logger = $logger;
}
public function import($organization, $repository, $numIssuesPerPage)
{
$issues = $this->fetchAllIssues($organization, $repository);
$this->logger->info("saving pages");
$issuesList = new Page();
$issuesList->save($issues, $numIssuesPerPage);
$this->logger->info("saved pages");
$this->logger->info("fetching comments for issues");
foreach ($issues as $issue) {
$comments = $this->fetchAllComments($organization, $repository, $issue);
$this->logger->debug("saving comments for #" . $issue["number"]);
$instance = new Issue();
$instance->save($issue, $comments);
}
@ -38,8 +49,12 @@ class GithubImporter {
public static function buildClient($clientId, $clientSecret)
{
$httpClient = new CachedHttpClient(array('cache_dir' => realpath('../tmp/github_api_cache')));
$client = new Client($httpClient);
$filesystemAdapter = new Local(realpath('../tmp/github_api_cache'));
$filesystem = new Filesystem($filesystemAdapter);
$pool = new FilesystemCachePool($filesystem);
$client = new Client();
$client->addCache($pool);
if (!empty($clientId) && !empty($clientSecret)) {
$client->authenticate($clientId, $clientSecret, Client::AUTH_URL_CLIENT_ID);
@ -50,22 +65,25 @@ class GithubImporter {
private function fetchAllIssues($organization, $repository)
{
$this->logger->info("fetching all issues");
$params = array(
$organization,
$repository,
array('filter' => 'all', 'state' => 'all', 'direction' => 'asc', 'sort' => 'created')
array('filter' => 'all', 'state' => 'all', 'direction' => 'desc', 'sort' => 'updated')
);
$paginator = new ResultPager($this->client);
$issuesApi = $this->client->api('issue');
$issues = $paginator->fetchAll($issuesApi, 'all', $params);
$this->logger->info("fetched all issues");
return $issues;
}
private function fetchAllComments($organization, $repository, $issue)
{
$this->logger->debug("fetching comments for #" . $issue["number"]);
if (empty($issue['comments'])) {
$this->logger->debug("no comments for #" . $issue["number"]);
return array();
}

View file

@ -8,6 +8,8 @@
namespace helpers;
use PHPMailer\PHPMailer\PHPMailer;
class Mail {
public static function sendEmail($subject, $message, $from, $to) {
@ -15,7 +17,7 @@ class Mail {
return;
}
$mail = new \PHPMailer();
$mail = new PHPMailer();
$mail->From = $from;
$mail->FromName = 'Issues Mirror';
$mail->AddReplyTo($from);

View file

@ -8,16 +8,9 @@
namespace helpers;
use \Michelf\MarkdownExtra as MarkdownParser;
class Markdown extends MarkdownParser {
protected function doHeaders($text)
{
// Do not transform headers, for instance because of backtraces which contain #0 #1 ...
// They are also not rendered by GitHub issues.
return $text;
}
class Markdown extends \Parsedown
{
/**
* Transform markdown to HTML. The HTML will be purified to prevent XSS.
@ -25,18 +18,44 @@ class Markdown extends MarkdownParser {
* @param string $markdown
* @return string
*/
public function transform($markdown)
{
$html = parent::transform($markdown);
public function text($markdown) {
$markdown = $this->parseMentions($markdown);
$markdown = $this->parseIssueMentions($markdown);
$this->setBreaksEnabled(true);
$html = parent::text($markdown);
$html = $this->removeUnsafeFileExtensions($html);
return $this->purifyHtml($html);
}
private function purifyHtml($html)
{
private function parseMentions($markdown) {
$regex = '/\@(\w+)/';
return preg_replace($regex, "<a class='mention' href='https://github.com/$1'>$0</a>", $markdown);
}
private function parseIssueMentions($markdown) {
$regex = '/#(\d+)/';
return preg_replace($regex, "<a href='/$1'>$0</a>", $markdown);
}
/**
* <a href="http://issues.piwik.org/attachments/1199/swelen_dateslider.swf">swelen_dateslider.swf</a>
* to
* <a href="http://issues.piwik.org/">swelen_dateslider.swf</a>
* @param $html
* @return string html
*/
private function removeUnsafeFileExtensions($html) {
$regex = '/attachments\/(.*?)\.(' . implode("|", FORBIDDEN_EXTENSIONS) . ')/';
return preg_replace($regex, "", $html);
}
private function purifyHtml($html) {
$config = \HTMLPurifier_Config::createDefault();
$config->set('HTML.Doctype', 'XHTML 1.0 Transitional');
$config->set('HTML.Allowed', 'p,strong,em,b,a[href],i,span,ul,ol,li,cite,code,pre');
$config->set('HTML.Allowed', 'p,strong,em,b,a[href],i,span,ul,ol,li,cite,code,pre,br,blockquote,img');
$config->set('HTML.AllowedAttributes', 'src, height, width, alt, href, class');
$config->set('URI.AllowedSchemes', array('http' => true, 'https' => true, 'mailto' => true, 'ftp' => true));
$config->set('HTML.TargetBlank', true);

View file

@ -8,7 +8,8 @@
namespace helpers;
class Page {
class Page
{
/**
* Get all details of the given page number.
@ -17,9 +18,8 @@ class Page {
* @param int $pageNumber
* @return bool
*/
public function getPage($pageNumber)
{
$path = $this->getPathToFile($pageNumber);
public function getPage($pageNumber) {
$path = $this->getPathToFile($pageNumber);
$content = file_get_contents($path);
return json_decode($content, true);
@ -30,8 +30,7 @@ class Page {
* @param int $pageNumber
* @return bool
*/
public function exists($pageNumber)
{
public function exists($pageNumber) {
$path = $this->getPathToFile($pageNumber);
return file_exists($path);
@ -41,10 +40,9 @@ class Page {
* Persist the given page on the file system.
*
* @param array $issues
* @param int $numIssuesPerPage
* @param int $numIssuesPerPage
*/
public function save($issues, $numIssuesPerPage)
{
public function save($issues, $numIssuesPerPage) {
if (empty($issues)) {
return;
}
@ -55,11 +53,11 @@ class Page {
$pageNumber = $index + 1;
$page = array(
'numPages' => count($pages),
'numPages' => count($pages),
'previousPage' => $pageNumber - 1,
'currentPage' => $pageNumber,
'nextPage' => $pageNumber + 1,
'issues' => $this->formatIssues($issuesInPage)
'currentPage' => $pageNumber,
'nextPage' => $pageNumber + 1,
'issues' => $this->formatIssues($issuesInPage)
);
$path = $this->getPathToFile($pageNumber);
@ -67,28 +65,45 @@ class Page {
}
}
private function getPathToFile($pageNumber)
{
private function getPathToFile($pageNumber) {
return sprintf('%s/../data/pages/%d.json', dirname(__FILE__), $pageNumber);
}
private function formatIssues($issuesInPage)
{
private function formatIssues($issuesInPage) {
$formatted = array();
foreach ($issuesInPage as $issue) {
$formatted[] = array(
'number' => $issue['number'],
'title' => $issue['title'],
'state' => $issue['state'],
'user' => array(
'title' => $issue['title'],
'state' => $issue['state'],
'user' => array(
'login' => $issue['user']['login']
),
'created_at' => $issue['created_at'],
'labels' => $issue['labels']
);
}
return $formatted;
}
public function getPaginationArray($numPages, $page, $padding = 2) {
$pages = [1];
$i = 2;
while ($i <= $numPages) {
if ($i < ($page - $padding - 1)) {
$pages[] = "d";
$i = $page - $padding;
} elseif (($i > ($page + $padding)) && ($numPages > ($page + $padding + 2))) {
# Wenn (
$pages[] = "d";
$i = $numPages;
}
$pages[] = $i;
$i++;
}
return $pages;
}
}

View file

@ -8,44 +8,58 @@
namespace helpers;
class Twig {
class Twig
{
public static function setDateFormat(\Twig_Environment $environment)
{
$environment->getExtension('core')->setDateFormat('F jS Y');
public static function setDateFormat(\Twig_Environment $environment) {
$environment->getExtension("Twig_Extension_Core")->setDateFormat('F jS Y');
}
public static function registerFilter(\Twig_Environment $environment)
{
public static function registerFilter(\Twig_Environment $environment) {
$environment->addFilter(static::getMarkdownFilter());
$environment->addFilter(static::getLinkToPageFilter());
$environment->addFilter(static::getLinkToIssueFilter());
$environment->addFilter(static::getColorFilter());
$environment->addFunction(static::getPaginationFunction());
}
private static function getMarkdownFilter()
{
private static function getMarkdownFilter() {
return new \Twig_SimpleFilter('markdown', function ($text) {
$parser = new Markdown();
return $parser->transform($text);
return $parser->text($text);
});
}
private static function getLinkToPageFilter()
{
return new \Twig_SimpleFilter('pageLink', function ($page) {
if (1 === (int) $page) {
return '/';
}
return '/?page=' . (int) $page;
}, array('is_safe' => array('all')));
private static function getColorFilter() {
return new \Twig_SimpleFilter(
/**
* modified from https://24ways.org/2010/calculating-color-contrast/
* @param $colorstring "#ffffff"
* @return string
*/
'textcolor', function ($hexcolor) {
$r = hexdec(substr($hexcolor, 0, 2));
$g = hexdec(substr($hexcolor, 2, 2));
$b = hexdec(substr($hexcolor, 4, 2));
$yiq = (($r * 299) + ($g * 587) + ($b * 114)) / 1000;
return ($yiq >= 128) ? 'black' : 'white';
});
}
private static function getLinkToIssueFilter()
{
return new \Twig_SimpleFilter('issueLink', function ($number) {
return '/' . (int) $number;
}, array('is_safe' => array('all')));
private static function getPaginationFunction() {
return new \Twig_Function('paginationFunction', function ($numPages, $page, $padding = 2) {
$pages = [1];
$i = 2;
while ($i <= $numPages) {
if ($i < ($page - $padding - 1)) {
$pages[] = "d";
$i = $page - $padding;
} elseif (($i > ($page + $padding)) && ($numPages > ($page + $padding + 2))) {
$pages[] = "d";
$i = $numPages;
}
$pages[] = $i;
$i++;
}
return $pages;
});
}
}

1533
src/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

17
src/package.json Normal file
View file

@ -0,0 +1,17 @@
{
"name": "src",
"version": "1.0.0",
"description": "",
"main": "index.js",
"dependencies": {
"bootstrap": "^4.0.0-beta",
"node-sass": "^4.5.3"
},
"devDependencies": {},
"scripts": {
"compileCSS": "node-sass --recursive --output-style compressed --output public/css --source-map true --source-map-contents scss",
"watchSCSS": "node-sass --watch --recursive --output-style compressed --output public/css --source-map true --source-map-contents scss"
},
"author": "",
"license": "ISC"
}

File diff suppressed because one or more lines are too long

View file

@ -1,391 +0,0 @@
{
"vars": {
"@gray-darker": "lighten(#000, 13.5%)",
"@gray-dark": "lighten(#000, 20%)",
"@gray": "lighten(#000, 33.5%)",
"@gray-light": "lighten(#000, 46.7%)",
"@gray-lighter": "lighten(#000, 93.5%)",
"@brand-primary": "#428bca",
"@brand-success": "#5cb85c",
"@brand-info": "#5bc0de",
"@brand-warning": "#f0ad4e",
"@brand-danger": "#d9534f",
"@body-bg": "#fff",
"@text-color": "@gray-dark",
"@link-color": "@brand-primary",
"@link-hover-color": "darken(@link-color, 15%)",
"@font-family-sans-serif": "\"Helvetica Neue\", Helvetica, Arial, sans-serif",
"@font-family-serif": "Georgia, \"Times New Roman\", Times, serif",
"@font-family-monospace": "Menlo, Monaco, Consolas, \"Courier New\", monospace",
"@font-family-base": "@font-family-sans-serif",
"@font-size-base": "14px",
"@font-size-large": "ceil((@font-size-base * 1.25))",
"@font-size-small": "ceil((@font-size-base * 0.85))",
"@font-size-h1": "floor((@font-size-base * 2.6))",
"@font-size-h2": "floor((@font-size-base * 2.15))",
"@font-size-h3": "ceil((@font-size-base * 1.7))",
"@font-size-h4": "ceil((@font-size-base * 1.25))",
"@font-size-h5": "@font-size-base",
"@font-size-h6": "ceil((@font-size-base * 0.85))",
"@line-height-base": "1.428571429",
"@line-height-computed": "floor((@font-size-base * @line-height-base))",
"@headings-font-family": "inherit",
"@headings-font-weight": "500",
"@headings-line-height": "1.1",
"@headings-color": "inherit",
"@icon-font-path": "\"../fonts/\"",
"@icon-font-name": "\"glyphicons-halflings-regular\"",
"@icon-font-svg-id": "\"glyphicons_halflingsregular\"",
"@padding-base-vertical": "6px",
"@padding-base-horizontal": "12px",
"@padding-large-vertical": "10px",
"@padding-large-horizontal": "16px",
"@padding-small-vertical": "5px",
"@padding-small-horizontal": "10px",
"@padding-xs-vertical": "1px",
"@padding-xs-horizontal": "5px",
"@line-height-large": "1.33",
"@line-height-small": "1.5",
"@border-radius-base": "4px",
"@border-radius-large": "6px",
"@border-radius-small": "3px",
"@component-active-color": "#fff",
"@component-active-bg": "@brand-primary",
"@caret-width-base": "4px",
"@caret-width-large": "5px",
"@table-cell-padding": "8px",
"@table-condensed-cell-padding": "5px",
"@table-bg": "transparent",
"@table-bg-accent": "#f9f9f9",
"@table-bg-hover": "#f5f5f5",
"@table-bg-active": "@table-bg-hover",
"@table-border-color": "#ddd",
"@btn-font-weight": "normal",
"@btn-default-color": "#333",
"@btn-default-bg": "#fff",
"@btn-default-border": "#ccc",
"@btn-primary-color": "#fff",
"@btn-primary-bg": "@brand-primary",
"@btn-primary-border": "darken(@btn-primary-bg, 5%)",
"@btn-success-color": "#fff",
"@btn-success-bg": "@brand-success",
"@btn-success-border": "darken(@btn-success-bg, 5%)",
"@btn-info-color": "#fff",
"@btn-info-bg": "@brand-info",
"@btn-info-border": "darken(@btn-info-bg, 5%)",
"@btn-warning-color": "#fff",
"@btn-warning-bg": "@brand-warning",
"@btn-warning-border": "darken(@btn-warning-bg, 5%)",
"@btn-danger-color": "#fff",
"@btn-danger-bg": "@brand-danger",
"@btn-danger-border": "darken(@btn-danger-bg, 5%)",
"@btn-link-disabled-color": "@gray-light",
"@input-bg": "#fff",
"@input-bg-disabled": "@gray-lighter",
"@input-color": "@gray",
"@input-border": "#ccc",
"@input-border-radius": "@border-radius-base",
"@input-border-focus": "#66afe9",
"@input-color-placeholder": "@gray-light",
"@input-height-base": "(@line-height-computed + (@padding-base-vertical * 2) + 2)",
"@input-height-large": "(ceil(@font-size-large * @line-height-large) + (@padding-large-vertical * 2) + 2)",
"@input-height-small": "(floor(@font-size-small * @line-height-small) + (@padding-small-vertical * 2) + 2)",
"@legend-color": "@gray-dark",
"@legend-border-color": "#e5e5e5",
"@input-group-addon-bg": "@gray-lighter",
"@input-group-addon-border-color": "@input-border",
"@dropdown-bg": "#fff",
"@dropdown-border": "rgba(0,0,0,.15)",
"@dropdown-fallback-border": "#ccc",
"@dropdown-divider-bg": "#e5e5e5",
"@dropdown-link-color": "@gray-dark",
"@dropdown-link-hover-color": "darken(@gray-dark, 5%)",
"@dropdown-link-hover-bg": "#f5f5f5",
"@dropdown-link-active-color": "@component-active-color",
"@dropdown-link-active-bg": "@component-active-bg",
"@dropdown-link-disabled-color": "@gray-light",
"@dropdown-header-color": "@gray-light",
"@dropdown-caret-color": "#000",
"@screen-xs": "480px",
"@screen-xs-min": "@screen-xs",
"@screen-phone": "@screen-xs-min",
"@screen-sm": "768px",
"@screen-sm-min": "@screen-sm",
"@screen-tablet": "@screen-sm-min",
"@screen-md": "992px",
"@screen-md-min": "@screen-md",
"@screen-desktop": "@screen-md-min",
"@screen-lg": "1200px",
"@screen-lg-min": "@screen-lg",
"@screen-lg-desktop": "@screen-lg-min",
"@screen-xs-max": "(@screen-sm-min - 1)",
"@screen-sm-max": "(@screen-md-min - 1)",
"@screen-md-max": "(@screen-lg-min - 1)",
"@grid-columns": "12",
"@grid-gutter-width": "30px",
"@grid-float-breakpoint": "@screen-sm-min",
"@grid-float-breakpoint-max": "(@grid-float-breakpoint - 1)",
"@container-tablet": "((720px + @grid-gutter-width))",
"@container-sm": "@container-tablet",
"@container-desktop": "((940px + @grid-gutter-width))",
"@container-md": "@container-desktop",
"@container-large-desktop": "((1140px + @grid-gutter-width))",
"@container-lg": "@container-large-desktop",
"@navbar-height": "50px",
"@navbar-margin-bottom": "@line-height-computed",
"@navbar-border-radius": "@border-radius-base",
"@navbar-padding-horizontal": "floor((@grid-gutter-width / 2))",
"@navbar-padding-vertical": "((@navbar-height - @line-height-computed) / 2)",
"@navbar-collapse-max-height": "340px",
"@navbar-default-color": "#777",
"@navbar-default-bg": "#f8f8f8",
"@navbar-default-border": "darken(@navbar-default-bg, 6.5%)",
"@navbar-default-link-color": "#777",
"@navbar-default-link-hover-color": "#333",
"@navbar-default-link-hover-bg": "transparent",
"@navbar-default-link-active-color": "#555",
"@navbar-default-link-active-bg": "darken(@navbar-default-bg, 6.5%)",
"@navbar-default-link-disabled-color": "#ccc",
"@navbar-default-link-disabled-bg": "transparent",
"@navbar-default-brand-color": "@navbar-default-link-color",
"@navbar-default-brand-hover-color": "darken(@navbar-default-brand-color, 10%)",
"@navbar-default-brand-hover-bg": "transparent",
"@navbar-default-toggle-hover-bg": "#ddd",
"@navbar-default-toggle-icon-bar-bg": "#888",
"@navbar-default-toggle-border-color": "#ddd",
"@navbar-inverse-color": "@gray-light",
"@navbar-inverse-bg": "#222",
"@navbar-inverse-border": "darken(@navbar-inverse-bg, 10%)",
"@navbar-inverse-link-color": "@gray-light",
"@navbar-inverse-link-hover-color": "#fff",
"@navbar-inverse-link-hover-bg": "transparent",
"@navbar-inverse-link-active-color": "@navbar-inverse-link-hover-color",
"@navbar-inverse-link-active-bg": "darken(@navbar-inverse-bg, 10%)",
"@navbar-inverse-link-disabled-color": "#444",
"@navbar-inverse-link-disabled-bg": "transparent",
"@navbar-inverse-brand-color": "@navbar-inverse-link-color",
"@navbar-inverse-brand-hover-color": "#fff",
"@navbar-inverse-brand-hover-bg": "transparent",
"@navbar-inverse-toggle-hover-bg": "#333",
"@navbar-inverse-toggle-icon-bar-bg": "#fff",
"@navbar-inverse-toggle-border-color": "#333",
"@nav-link-padding": "10px 15px",
"@nav-link-hover-bg": "@gray-lighter",
"@nav-disabled-link-color": "@gray-light",
"@nav-disabled-link-hover-color": "@gray-light",
"@nav-open-link-hover-color": "#fff",
"@nav-tabs-border-color": "#ddd",
"@nav-tabs-link-hover-border-color": "@gray-lighter",
"@nav-tabs-active-link-hover-bg": "@body-bg",
"@nav-tabs-active-link-hover-color": "@gray",
"@nav-tabs-active-link-hover-border-color": "#ddd",
"@nav-tabs-justified-link-border-color": "#ddd",
"@nav-tabs-justified-active-link-border-color": "@body-bg",
"@nav-pills-border-radius": "@border-radius-base",
"@nav-pills-active-link-hover-bg": "@component-active-bg",
"@nav-pills-active-link-hover-color": "@component-active-color",
"@pagination-color": "@link-color",
"@pagination-bg": "#fff",
"@pagination-border": "#ddd",
"@pagination-hover-color": "@link-hover-color",
"@pagination-hover-bg": "@gray-lighter",
"@pagination-hover-border": "#ddd",
"@pagination-active-color": "#fff",
"@pagination-active-bg": "@brand-primary",
"@pagination-active-border": "@brand-primary",
"@pagination-disabled-color": "@gray-light",
"@pagination-disabled-bg": "#fff",
"@pagination-disabled-border": "#ddd",
"@pager-bg": "@pagination-bg",
"@pager-border": "@pagination-border",
"@pager-border-radius": "15px",
"@pager-hover-bg": "@pagination-hover-bg",
"@pager-active-bg": "@pagination-active-bg",
"@pager-active-color": "@pagination-active-color",
"@pager-disabled-color": "@pagination-disabled-color",
"@jumbotron-padding": "30px",
"@jumbotron-color": "inherit",
"@jumbotron-bg": "@gray-lighter",
"@jumbotron-heading-color": "inherit",
"@jumbotron-font-size": "ceil((@font-size-base * 1.5))",
"@state-success-text": "#3c763d",
"@state-success-bg": "#dff0d8",
"@state-success-border": "darken(spin(@state-success-bg, -10), 5%)",
"@state-info-text": "#31708f",
"@state-info-bg": "#d9edf7",
"@state-info-border": "darken(spin(@state-info-bg, -10), 7%)",
"@state-warning-text": "#8a6d3b",
"@state-warning-bg": "#fcf8e3",
"@state-warning-border": "darken(spin(@state-warning-bg, -10), 5%)",
"@state-danger-text": "#a94442",
"@state-danger-bg": "#f2dede",
"@state-danger-border": "darken(spin(@state-danger-bg, -10), 5%)",
"@tooltip-max-width": "200px",
"@tooltip-color": "#fff",
"@tooltip-bg": "#000",
"@tooltip-opacity": ".9",
"@tooltip-arrow-width": "5px",
"@tooltip-arrow-color": "@tooltip-bg",
"@popover-bg": "#fff",
"@popover-max-width": "276px",
"@popover-border-color": "rgba(0,0,0,.2)",
"@popover-fallback-border-color": "#ccc",
"@popover-title-bg": "darken(@popover-bg, 3%)",
"@popover-arrow-width": "10px",
"@popover-arrow-color": "#fff",
"@popover-arrow-outer-width": "(@popover-arrow-width + 1)",
"@popover-arrow-outer-color": "fadein(@popover-border-color, 5%)",
"@popover-arrow-outer-fallback-color": "darken(@popover-fallback-border-color, 20%)",
"@label-default-bg": "@gray-light",
"@label-primary-bg": "@brand-primary",
"@label-success-bg": "@brand-success",
"@label-info-bg": "@brand-info",
"@label-warning-bg": "@brand-warning",
"@label-danger-bg": "@brand-danger",
"@label-color": "#fff",
"@label-link-hover-color": "#fff",
"@modal-inner-padding": "15px",
"@modal-title-padding": "15px",
"@modal-title-line-height": "@line-height-base",
"@modal-content-bg": "#fff",
"@modal-content-border-color": "rgba(0,0,0,.2)",
"@modal-content-fallback-border-color": "#999",
"@modal-backdrop-bg": "#000",
"@modal-backdrop-opacity": ".5",
"@modal-header-border-color": "#e5e5e5",
"@modal-footer-border-color": "@modal-header-border-color",
"@modal-lg": "900px",
"@modal-md": "600px",
"@modal-sm": "300px",
"@alert-padding": "15px",
"@alert-border-radius": "@border-radius-base",
"@alert-link-font-weight": "bold",
"@alert-success-bg": "@state-success-bg",
"@alert-success-text": "@state-success-text",
"@alert-success-border": "@state-success-border",
"@alert-info-bg": "@state-info-bg",
"@alert-info-text": "@state-info-text",
"@alert-info-border": "@state-info-border",
"@alert-warning-bg": "@state-warning-bg",
"@alert-warning-text": "@state-warning-text",
"@alert-warning-border": "@state-warning-border",
"@alert-danger-bg": "@state-danger-bg",
"@alert-danger-text": "@state-danger-text",
"@alert-danger-border": "@state-danger-border",
"@progress-bg": "#f5f5f5",
"@progress-bar-color": "#fff",
"@progress-bar-bg": "@brand-primary",
"@progress-bar-success-bg": "@brand-success",
"@progress-bar-warning-bg": "@brand-warning",
"@progress-bar-danger-bg": "@brand-danger",
"@progress-bar-info-bg": "@brand-info",
"@list-group-bg": "#fff",
"@list-group-border": "#ddd",
"@list-group-border-radius": "@border-radius-base",
"@list-group-hover-bg": "#f5f5f5",
"@list-group-active-color": "@component-active-color",
"@list-group-active-bg": "@component-active-bg",
"@list-group-active-border": "@list-group-active-bg",
"@list-group-active-text-color": "lighten(@list-group-active-bg, 40%)",
"@list-group-disabled-color": "@gray-light",
"@list-group-disabled-bg": "@gray-lighter",
"@list-group-disabled-text-color": "@list-group-disabled-color",
"@list-group-link-color": "#555",
"@list-group-link-hover-color": "@list-group-link-color",
"@list-group-link-heading-color": "#333",
"@panel-bg": "#fff",
"@panel-body-padding": "15px",
"@panel-heading-padding": "10px 15px",
"@panel-footer-padding": "@panel-heading-padding",
"@panel-border-radius": "@border-radius-base",
"@panel-inner-border": "#ddd",
"@panel-footer-bg": "#f5f5f5",
"@panel-default-text": "@gray-dark",
"@panel-default-border": "#ddd",
"@panel-default-heading-bg": "#f5f5f5",
"@panel-primary-text": "#fff",
"@panel-primary-border": "@brand-primary",
"@panel-primary-heading-bg": "@brand-primary",
"@panel-success-text": "@state-success-text",
"@panel-success-border": "@state-success-border",
"@panel-success-heading-bg": "@state-success-bg",
"@panel-info-text": "@state-info-text",
"@panel-info-border": "@state-info-border",
"@panel-info-heading-bg": "@state-info-bg",
"@panel-warning-text": "@state-warning-text",
"@panel-warning-border": "@state-warning-border",
"@panel-warning-heading-bg": "@state-warning-bg",
"@panel-danger-text": "@state-danger-text",
"@panel-danger-border": "@state-danger-border",
"@panel-danger-heading-bg": "@state-danger-bg",
"@thumbnail-padding": "4px",
"@thumbnail-bg": "@body-bg",
"@thumbnail-border": "#ddd",
"@thumbnail-border-radius": "@border-radius-base",
"@thumbnail-caption-color": "@text-color",
"@thumbnail-caption-padding": "9px",
"@well-bg": "#f5f5f5",
"@well-border": "darken(@well-bg, 7%)",
"@badge-color": "#fff",
"@badge-link-hover-color": "#fff",
"@badge-bg": "@gray-light",
"@badge-active-color": "@link-color",
"@badge-active-bg": "#fff",
"@badge-font-weight": "bold",
"@badge-border-radius": "10px",
"@breadcrumb-padding-vertical": "8px",
"@breadcrumb-padding-horizontal": "15px",
"@breadcrumb-bg": "#f5f5f5",
"@breadcrumb-color": "#ccc",
"@breadcrumb-active-color": "@gray-light",
"@breadcrumb-separator": "\"/\"",
"@carousel-text-shadow": "0 1px 2px rgba(0,0,0,.6)",
"@carousel-control-color": "#fff",
"@carousel-control-width": "15%",
"@carousel-control-opacity": ".5",
"@carousel-control-font-size": "20px",
"@carousel-indicator-active-bg": "#fff",
"@carousel-indicator-border-color": "#fff",
"@carousel-caption-color": "#fff",
"@close-font-weight": "bold",
"@close-color": "#000",
"@close-text-shadow": "0 1px 0 #fff",
"@code-color": "#c7254e",
"@code-bg": "#f9f2f4",
"@kbd-color": "#fff",
"@kbd-bg": "#333",
"@pre-bg": "#f5f5f5",
"@pre-color": "@gray-dark",
"@pre-border-color": "#ccc",
"@pre-scrollable-max-height": "340px",
"@component-offset-horizontal": "180px",
"@text-muted": "@gray-light",
"@abbr-border-color": "@gray-light",
"@headings-small-color": "@gray-light",
"@blockquote-small-color": "@gray-light",
"@blockquote-font-size": "(@font-size-base * 1.25)",
"@blockquote-border-color": "@gray-lighter",
"@page-header-border-color": "@gray-lighter",
"@dl-horizontal-offset": "@component-offset-horizontal",
"@hr-border": "@gray-lighter"
},
"css": [
"print.less",
"type.less",
"code.less",
"grid.less",
"buttons.less",
"responsive-utilities.less",
"pagination.less",
"pager.less",
"labels.less",
"badges.less",
"jumbotron.less",
"list-group.less",
"panels.less",
"wells.less",
"close.less"
],
"js": [],
"customizerUrl": "http://getbootstrap.com/customize/?id=c93b35180144c3316210"
}

View file

@ -10,26 +10,40 @@ require '../vendor/autoload.php';
require '../config/config.php';
date_default_timezone_set('UTC');
$config = [
'settings' => [
'displayErrorDetails' => true,
],
];
$app = new \Slim\Slim(array(
'view' => new \Slim\Views\Twig(),
'debug' => DEBUG_ENABLED
));
$app = new \Slim\App($config);
/** @var \Slim\Views\Twig $view */
$view = $app->view();
$view->parserOptions = array(
'charset' => 'utf-8',
'debug' => DEBUG_ENABLED,
'cache' => realpath('../tmp/templates_cache'),
'autoescape' => true
);
$view->parserExtensions = array(
new \Slim\Views\TwigExtension()
);
$view->setTemplatesDirectory(realpath('../templates'));
helpers\Twig::setDateFormat($view->getEnvironment());
helpers\Twig::registerFilter($view->getEnvironment());
// Get container
$container = $app->getContainer();
// Register component on container
$container['view'] = function ($container) {
$view = new \Slim\Views\Twig(realpath("../templates/"), [
'cache' => realpath('../tmp/templates_cache'),
'debug' => DEBUG_ENABLED,
]);
// Instantiate and add Slim specific extension
$basePath = rtrim(str_ireplace('index.php', '', $container['request']->getUri()->getBasePath()), '/');
$view->addExtension(new Slim\Views\TwigExtension($container['router'], $basePath));
$view->addExtension(new \Twig_Extension_Debug());
$twig = $view->getEnvironment();
helpers\Twig::setDateFormat($twig);
helpers\Twig::registerFilter($twig);
$view->getEnvironment()->addGlobal('projectName', PROJECT_NAME);
$view->getEnvironment()->addGlobal('githubOrganization', GITHUB_ORGANIZATION);
$view->getEnvironment()->addGlobal('githubRepository', GITHUB_REPOSITORY);
$view->getEnvironment()->addGlobal('piwikURL', PIWIK_URL);
$view->getEnvironment()->addGlobal('piwikID', PIWIK_ID);
return $view;
};
require '../routes/page.php';

View file

@ -6,42 +6,33 @@
* @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
*/
function initView($app)
{
$app->view->setData('projectName', PROJECT_NAME);
$app->view->setData('githubOrganization', GITHUB_ORGANIZATION);
$app->view->setData('githubRepository', GITHUB_REPOSITORY);
}
initView($app);
$app->get(
'/{number:[0-9]+}', function ($request, $response, $args) {
$app->get('/:number', function ($number) use ($app) {
$number = (int) $number;
$number = (int)$args["number"];
$issue = new helpers\Issue();
if (!$issue->exists($number)) {
$app->pass();
return;
throw new \Slim\Exception\NotFoundException($request, $response);
}
$details = $issue->getIssue($number);
return $this->view->render($response, 'issue.twig', $details);
})->setName("issue");
$app->render('issue.twig', $details);
})->conditions(array('number' => '\d+'));
$app->get('/', function() use ($app) {
$pageNumber = (int) $app->request()->get('page', 1);
$app->get('/', function ($request, $response, $args) {
/** @var \Slim\Http\Request $request */
$pageNumber = (int)$request->getQueryParam('page', 1);
$page = new helpers\Page();
if (!$page->exists($pageNumber)) {
$app->pass();
return;
throw new \Slim\Exception\NotFoundException($request, $response);
}
$details = $page->getPage($pageNumber);
$app->render('page.twig', $details);
});
return $this->view->render($response, 'page.twig', $details);
})->setName("page");

102
src/scss/style.scss Normal file
View file

@ -0,0 +1,102 @@
$grid-gutter-width: 10px;
@import "../node_modules/bootstrap/scss/bootstrap";
.card-deck {
.row {
align-items: stretch;
width: 100%;
> div {
display: flex;
margin-bottom: 2*$grid-gutter-width;
.card {
color: $gray-900;
small {
color: $gray-700;
}
text-decoration: none;
&:hover, &:focus, &:active {
border: 1px solid rgba(0, 0, 0, 0.5);
background: $gray-200;
}
}
}
}
}
h1 a {
color: $gray-700;
&:hover, &:focus, &:active {
color: $gray-900;
}
}
.badge {
padding: 6px 10px;
white-space: normal;
&.text-black {
color: $gray-800;
}
&.text-white {
color: $gray-200;
}
}
.card {
margin-bottom: 2rem;
&.overviewCard {
margin-bottom: 0;
}
.avatar {
margin-right: 3px;
}
.card-header {
display: flex;
align-items: center;
a {
display: flex;
align-items: center;
margin-right: 3px;
}
.authorAssociation {
margin-left: auto;
margin-right: 0;
display: block;
font-size: smaller;
color: $gray-700;
}
}
}
.pagination {
svg {
height: 15px;
width: 15px;
}
}
.mention {
font-weight: bold;
color: $gray-900;
}
blockquote { // bootstrap 3 like styling
padding: 10px 20px;
margin: 0 0 20px;
font-size: 17.5px;
border-left: 5px solid $gray-300;
}
pre > code {
display: block;
padding: 9.5px;
margin: 0 0 10px;
color: $gray-800;
word-break: break-all;
word-wrap: break-word;
background-color: $gray-200;
border: 1px solid $gray-400;
border-radius: $border-radius;
overflow-y: auto;
}

View file

@ -13,10 +13,21 @@
require '../vendor/autoload.php';
require '../config/config.php';
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
$logger = new Logger('import_log');
$logger->pushHandler(new StreamHandler(__DIR__ . '/../tmp/import.log', Logger::DEBUG));
if (DEBUG_ENABLED) {
$logger->pushHandler(new \Monolog\Handler\ErrorLogHandler());
}
$logger->info("authenticating");
$client = helpers\GithubImporter::buildClient(GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET);
$importer = new helpers\GithubImporter($client);
$importer = new helpers\GithubImporter($client, $logger);
try {
$logger->info("starting import");
$importer->import(GITHUB_ORGANIZATION, GITHUB_REPOSITORY, NUMBER_OF_ISSUES_PER_PAGE);
} catch (Exception $e) {
helpers\Mail::sendEmail('Import error', $e->getMessage(), PROJECT_EMAIL, PROJECT_EMAIL);

View file

@ -2,34 +2,48 @@
{% import "macros.twig" as macro %}
{% block title %}
<title>{{ projectName }} Issue {{number}} - {{ title }}</title>
<title>{{ projectName }} {{ pull_request ? "Pull Request" : "Issue" }} #{{ number }} - {{ title }}</title>
{% endblock %}
{% block content %}
<div class="container">
<div class="page-header">
<h1>{{ title }} <small>#{{number}}</small></h1>
{% if labels and labels|length %}
{% for label in labels %}
<span class="label label-default">{{ label.name }}</span>
{% endfor %}
<h1>{{ title }}
<a href="{{ html_url }}">
<small>#{{ number }}</small>
</a>
</h1>
{{ macro.labels(labels) }}
{% if milestone %}
<span class="badge badge-secondary" title="{{ milestone.description }}">{{ milestone.title }}</span>
{% endif %}
</div>
</div>
<hr>
<div class="container">
<div class="panel panel-default">
<div class="panel-heading">{{ macro.username(user) }} opened this issue on {{ created_at|date }}</div>
<div class="panel-body">
<div class="card">
<div class="card-header">
{{ macro.user(user) }} opened this {{ pull_request ? "Pull Request" : "Issue" }} on {{ created_at|date }}
{% if author_association != "NONE" %}
<span class="authorAssociation">{{ author_association | capitalize }}</span>
{% endif %}
</div>
<div class="card-body">
{{ body|markdown|raw }}
</div>
</div>
{% if comments|length %}
{% for comment in comments %}
<div class="panel panel-default">
<div class="panel-heading">{{ macro.username(comment.user) }} commented on {{ comment.created_at|date }}</div>
<div class="panel-body">
<div class="card">
<div class="card-header">{{ macro.user(comment.user) }} commented
on {{ comment.created_at|date }}
{% if comment.author_association != "NONE" %}
<span class="authorAssociation">{{ comment.author_association | capitalize }}</span>
{% endif %}
</div>
<div class="card-body">
{{ comment.body|markdown|raw }}
</div>
</div>
@ -37,8 +51,10 @@
{% endif %}
{% if state == 'closed' %}
<div class="well well-sm">
This issue was closed on {{ closed_at|date }}
<div class="card">
<div class="card-header">
This {{ pull_request ? "Pull Request" : "Issue" }} was closed on {{ closed_at|date }}
</div>
</div>
{% endif %}
</div>
@ -47,7 +63,7 @@
<div class="row">
<div class="col-md-6">
<a href="/">Home</a> |
<a onclick="window.history.back();" style="cursor: pointer;">Back</a>
<a href="javascript:window.history.back();">Back</a>
</div>
</div>
</div>

View file

@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="/css/bootstrap.min.css" rel="stylesheet">
<link href="/css/style.css" rel="stylesheet">
{% block head %}
{% endblock %}
@ -17,13 +17,35 @@
{% block content %}{% endblock %}
<div class="container" style="margin-bottom: 20px;">
<div class="row">
<div class="col-md-6">
Powered by <a target="_blank" href="https://github.com/piwik/github-issues-mirror">GitHub Issue Mirror</a>
</div>
<div class="container" style="margin-bottom: 20px;">
<div class="row">
<div class="col-md-6">
Powered by <a target="_blank" href="https://github.com/piwik/github-issues-mirror">GitHub Issue Mirror</a>
</div>
</div>
</div>
{% if piwikURL and piwikID %}
<!-- Piwik -->
<script type="text/javascript">
var _paq = _paq || [];
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function () {
var u = "{{ piwikURL }}";
_paq.push(['setTrackerUrl', u + 'piwik.php']);
_paq.push(['setSiteId', '{{ piwikID }}']);
var d = document, g = d.createElement('script'), s = d.getElementsByTagName('script')[0];
g.type = 'text/javascript';
g.async = true;
g.defer = true;
g.src = u + 'piwik.js';
s.parentNode.insertBefore(g, s);
})();
</script>
<!-- End Piwik Code -->
{% endif %}
</body>
</html>

View file

@ -1,3 +1,71 @@
{% macro username(user) %}
<a href="{{ user.html_url }}" target="_blank">@{{ user.login }}</a>
{% macro user(user) %}
<a href="{{ user.html_url }}" target="_blank">
<img class="avatar" src="{{ user.avatar_url }}&amp;s=44" role="presentation" aria-hidden="true" width="44"
height="44">@{{ user.login }}
</a>
{% endmacro %}
{% macro labels(labels) %}
{% if labels and labels|length %}
{% for label in labels %}
<span class="badge text-{{ label.color | textcolor }}"
style="background: {{ '#' ~ label.color }}">{{ label.name }}</span>
{% endfor %}
{% endif %}
{% endmacro %}
{% macro pagination(num_pages, page) %}
{% import _self as m %}
{% set pagearray = paginationFunction(num_pages, page,5) %}
<!-- License of svg icons - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) -->
<ul class="pagination">
{% if page > 1 %}
<li class="page-item">
<a rel="prev" href="{{ path_for('page', [], { 'page': page - 1 }) }}" class="page-link">
<svg viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path d="M1203 544q0 13-10 23l-393 393 393 393q10 10 10 23t-10 23l-50 50q-10 10-23 10t-23-10l-466-466q-10-10-10-23t10-23l466-466q10-10 23-10t23 10l50 50q10 10 10 23z"></path>
</svg>
</a>
</li>
{% else %}
<li class="page-item disabled">
<a rel="prev" href="" class="page-link">
<svg viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path d="M1203 544q0 13-10 23l-393 393 393 393q10 10 10 23t-10 23l-50 50q-10 10-23 10t-23-10l-466-466q-10-10-10-23t10-23l466-466q10-10 23-10t23 10l50 50q10 10 10 23z"></path>
</svg>
</a>
</li>
{% endif %}
{% for i in pagearray %}
{% if i == "d" %}
<li class="page-item disabled">
<a href="#" class=" other page-link">&hellip;</a>
</li>
{% else %}
{{ m.printpage(i, page) }}
{% endif %}
{% endfor %}
<li class="page-item">
{% if page < num_pages %}
<a rel="next" href="{{ path_for('page', [], { 'page': page + 1 }) }}" class="page-link">
<svg viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path d="M1171 960q0 13-10 23l-466 466q-10 10-23 10t-23-10l-50-50q-10-10-10-23t10-23l393-393-393-393q-10-10-10-23t10-23l50-50q10-10 23-10t23 10l466 466q10 10 10 23z"></path>
</svg>
</a>
{% else %}
<a rel="next" href="" class="disabled page-link">
<svg viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path d="M1171 960q0 13-10 23l-466 466q-10 10-23 10t-23-10l-50-50q-10-10-10-23t10-23l393-393-393-393q-10-10-10-23t10-23l50-50q10-10 23-10t23 10l466 466q10 10 10 23z"></path>
</svg>
</a>
{% endif %}
</li>
</ul>
{% endmacro %}
{% macro printpage(i,active) %}
<li class="page-item {{ i == active ? "active" : "other" }}">
<a href="{{ path_for('page', [], { 'page': i }) }}"
class="page-link">{{ i }}</a>
</li>
{% endmacro %}

View file

@ -1,12 +1,13 @@
{% extends "layout.twig" %}
{% import "macros.twig" as macro %}
{% block head %}
{% if currentPage > 1 %}
<link rel="prev" href="{{ previousPage|pageLink }}" />
<link rel="prev" href="{{ path_for('page', [] , { 'page': previousPage }) }}"/>
{% endif %}
{% if currentPage != numPages %}
<link rel="next" href="{{ nextPage|pageLink }}" />
<link rel="next" href="{{ path_for('page', [], { 'page': nextPage }) }}"/>
{% endif %}
{% endblock %}
@ -14,51 +15,45 @@
{% if currentPage == 1 %}
<div class="container">
<div class="page-header">
<h1>Read-only mirror of all <a href="http://github.com/{{ githubOrganization|e('url') }}/{{ githubRepository|e('url') }}/issues" target="_blank">{{ projectName }} issues</a>.</h1>
<h1>Read-only mirror of all <a
href="https://github.com/{{ githubOrganization|e('url') }}/{{ githubRepository|e('url') }}/issues"
target="_blank">{{ projectName }} issues</a>.</h1>
</div>
</div>
{% else %}
<div class="container">
<div class="page-header">
<h1>{{ projectName }} Issues <small>Page {{currentPage}}</small></h1>
<h1>{{ projectName }} Issues
<small>Page {{ currentPage }}</small>
</h1>
</div>
</div>
{% endif %}
<hr>
<div class="container">
{% for issue in issues %}
<div class="list-group">
<a href="{{ issue.number|issueLink }}" class="list-group-item">
<h4 class="list-group-item-heading">{{ issue.title }} <small>#{{ issue.number }}</small></h4>
<p class="list-group-item-text">
<small>
opened by @{{ issue.user.login }} on {{ issue.created_at|date }}
{% if issue.state == 'closed' %}
<span class="label label-default">closed</span>
{% endif %}
</small>
</p>
</a>
</div>
{% endfor %}
<ul class="pagination">
{% if currentPage > 1 %}
<li><a href="{{ previousPage|pageLink }}" title="Previous page">&laquo;</a></li>
{% endif %}
{% for pageEntry in 1..numPages %}
<li {% if pageEntry == currentPage %}class="active"{% endif %}>
<a href="{{ pageEntry|pageLink }}" title="Page {{ pageEntry }}">{{ pageEntry }}</a>
</li>
{{ macro.pagination(numPages, currentPage) }}
<div class="card-deck"><div class="row">
{% for issue in issues %}
<div class="col-lg-3 col-md-4">
<a href="{{ path_for('issue', { 'number': issue.number }) }}" class="card overviewCard">
<div class="card-body">
<h4 class="card-title">{{ issue.title }}
<small>#{{ issue.number }}</small>
</h4>
<small>
opened by @{{ issue.user.login }} on {{ issue.created_at|date }}
{% if issue.state == 'closed' %}
<span class="badge badge-secondary">closed</span>
{% endif %}
{{ macro.labels(issue.labels) }}
</small>
</div>
</a>
</div>
{% endfor %}
{% if currentPage != numPages %}
<li><a href="{{ nextPage|pageLink }}" title="Next page">&raquo;</a></li>
{% endif %}
</ul>
</div></div>
{{ macro.pagination(numPages, currentPage) }}
</div>
{% endblock %}