Clean logs on start - and eslint/mocha with code coverage (#1945)
* Clean logs on startup; install server-side testing/linting * Add eslint config, make all of app/ conform to its demands * Add Node.js testing and linting to CI * Lock project to Node.js 7.9.0, used by Electron 1.7.10 * New eslint error: trailing commas in function argumensts Node 7.9.0 doesn't like trailing commas, but Electron does * Move electron to devDependency, tell eslint it's built-in
This commit is contained in:
parent
6464d0a5fa
commit
64fe9dbfb2
21 changed files with 1782 additions and 316 deletions
|
@ -1,4 +1,4 @@
|
|||
const autoUpdater = require('electron-updater').autoUpdater
|
||||
const { autoUpdater } = require('electron-updater');
|
||||
const { dialog } = require('electron');
|
||||
|
||||
const config = require('./config');
|
||||
|
@ -18,7 +18,7 @@ function checkForUpdates() {
|
|||
autoUpdater.checkForUpdates();
|
||||
}
|
||||
|
||||
var showingDialog = false;
|
||||
let showingDialog = false;
|
||||
function showUpdateDialog(mainWindow, messages) {
|
||||
if (showingDialog) {
|
||||
return;
|
||||
|
@ -29,21 +29,21 @@ function showUpdateDialog(mainWindow, messages) {
|
|||
type: 'info',
|
||||
buttons: [
|
||||
messages.autoUpdateRestartButtonLabel.message,
|
||||
messages.autoUpdateLaterButtonLabel.message
|
||||
messages.autoUpdateLaterButtonLabel.message,
|
||||
],
|
||||
title: messages.autoUpdateNewVersionTitle.message,
|
||||
message: messages.autoUpdateNewVersionMessage.message,
|
||||
detail: messages.autoUpdateNewVersionInstructions.message,
|
||||
defaultId: LATER_BUTTON,
|
||||
cancelId: RESTART_BUTTON,
|
||||
}
|
||||
};
|
||||
|
||||
dialog.showMessageBox(mainWindow, options, function(response) {
|
||||
if (response == RESTART_BUTTON) {
|
||||
dialog.showMessageBox(mainWindow, options, (response) => {
|
||||
if (response === RESTART_BUTTON) {
|
||||
// We delay these update calls because they don't seem to work in this
|
||||
// callback - but only if the message box has a parent window.
|
||||
// Fixes this bug: https://github.com/WhisperSystems/Signal-Desktop/issues/1864
|
||||
setTimeout(function() {
|
||||
setTimeout(() => {
|
||||
windowState.markShouldQuit();
|
||||
autoUpdater.quitAndInstall();
|
||||
}, 200);
|
||||
|
@ -54,7 +54,7 @@ function showUpdateDialog(mainWindow, messages) {
|
|||
}
|
||||
|
||||
function onError(error) {
|
||||
console.log("Got an error while updating: ", error.stack);
|
||||
console.log('Got an error while updating: ', error.stack);
|
||||
}
|
||||
|
||||
function initialize(getMainWindow, messages) {
|
||||
|
@ -66,7 +66,7 @@ function initialize(getMainWindow, messages) {
|
|||
return;
|
||||
}
|
||||
|
||||
autoUpdater.addListener('update-downloaded', function() {
|
||||
autoUpdater.addListener('update-downloaded', () => {
|
||||
showUpdateDialog(getMainWindow(), messages);
|
||||
});
|
||||
autoUpdater.addListener('error', onError);
|
||||
|
@ -77,5 +77,5 @@ function initialize(getMainWindow, messages) {
|
|||
}
|
||||
|
||||
module.exports = {
|
||||
initialize
|
||||
initialize,
|
||||
};
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
const path = require('path');
|
||||
|
||||
const config = require('config');
|
||||
|
||||
const packageJson = require('../package.json');
|
||||
|
||||
|
||||
const environment = packageJson.environment || process.env.NODE_ENV || 'development';
|
||||
config.environment = environment;
|
||||
|
||||
// Set environment vars to configure node-config before requiring it
|
||||
process.env.NODE_ENV = environment;
|
||||
|
@ -19,8 +22,6 @@ if (environment === 'production') {
|
|||
process.env.SUPPRESS_NO_CONFIG_WARNING = '';
|
||||
}
|
||||
|
||||
const config = require('config');
|
||||
config.environment = environment;
|
||||
|
||||
// Log resulting env vars in use by config
|
||||
[
|
||||
|
@ -30,9 +31,9 @@ config.environment = environment;
|
|||
'ALLOW_CONFIG_MUTATIONS',
|
||||
'HOSTNAME',
|
||||
'NODE_APP_INSTANCE',
|
||||
'SUPPRESS_NO_CONFIG_WARNING'
|
||||
].forEach(function(s) {
|
||||
console.log(s + ' ' + config.util.getEnv(s));
|
||||
'SUPPRESS_NO_CONFIG_WARNING',
|
||||
].forEach((s) => {
|
||||
console.log(`${s} ${config.util.getEnv(s)}`);
|
||||
});
|
||||
|
||||
module.exports = config;
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const app = require('electron').app;
|
||||
const { app } = require('electron');
|
||||
const _ = require('lodash');
|
||||
|
||||
const logger = require('./logging').getLogger();
|
||||
const logging = require('./logging');
|
||||
|
||||
function normalizeLocaleName(locale) {
|
||||
if (/^en-/.test(locale)) {
|
||||
|
@ -28,7 +28,8 @@ function getLocaleMessages(locale) {
|
|||
}
|
||||
|
||||
function load() {
|
||||
let english = getLocaleMessages('en');
|
||||
const logger = logging.getLogger();
|
||||
const english = getLocaleMessages('en');
|
||||
let appLocale = app.getLocale();
|
||||
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
|
@ -49,7 +50,7 @@ function load() {
|
|||
// We start with english, then overwrite that with anything present in locale
|
||||
messages = _.merge(english, messages);
|
||||
} catch (e) {
|
||||
logger.error('Problem loading messages for locale ' + localeName + ' ' + e.stack);
|
||||
logger.error(`Problem loading messages for locale ${localeName} ${e.stack}`);
|
||||
logger.error('Falling back to en locale');
|
||||
|
||||
localeName = 'en';
|
||||
|
@ -58,10 +59,10 @@ function load() {
|
|||
|
||||
return {
|
||||
name: localeName,
|
||||
messages
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
load: load
|
||||
load,
|
||||
};
|
||||
|
|
202
app/logging.js
202
app/logging.js
|
@ -1,22 +1,31 @@
|
|||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const electron = require('electron')
|
||||
const electron = require('electron');
|
||||
const bunyan = require('bunyan');
|
||||
const mkdirp = require('mkdirp');
|
||||
const _ = require('lodash');
|
||||
const readFirstLine = require('firstline');
|
||||
const readLastLines = require('read-last-lines').read;
|
||||
|
||||
|
||||
const app = electron.app;
|
||||
const ipc = electron.ipcMain;
|
||||
const {
|
||||
app,
|
||||
ipcMain: ipc,
|
||||
} = electron;
|
||||
const LEVELS = ['fatal', 'error', 'warn', 'info', 'debug', 'trace'];
|
||||
|
||||
let logger;
|
||||
|
||||
|
||||
function dropFirst(args) {
|
||||
return Array.prototype.slice.call(args, 1);
|
||||
}
|
||||
module.exports = {
|
||||
initialize,
|
||||
getLogger,
|
||||
// for tests only:
|
||||
isLineAfterDate,
|
||||
eliminateOutOfDateFiles,
|
||||
eliminateOldEntries,
|
||||
fetchLog,
|
||||
fetch,
|
||||
};
|
||||
|
||||
function initialize() {
|
||||
if (logger) {
|
||||
|
@ -27,38 +36,114 @@ function initialize() {
|
|||
const logPath = path.join(basePath, 'logs');
|
||||
mkdirp.sync(logPath);
|
||||
|
||||
const logFile = path.join(logPath, 'log.log');
|
||||
return cleanupLogs(logPath).then(() => {
|
||||
const logFile = path.join(logPath, 'log.log');
|
||||
|
||||
logger = bunyan.createLogger({
|
||||
name: 'log',
|
||||
streams: [{
|
||||
level: 'debug',
|
||||
stream: process.stdout
|
||||
}, {
|
||||
type: 'rotating-file',
|
||||
path: logFile,
|
||||
period: '1d',
|
||||
count: 3
|
||||
}]
|
||||
});
|
||||
logger = bunyan.createLogger({
|
||||
name: 'log',
|
||||
streams: [{
|
||||
level: 'debug',
|
||||
stream: process.stdout,
|
||||
}, {
|
||||
type: 'rotating-file',
|
||||
path: logFile,
|
||||
period: '1d',
|
||||
count: 3,
|
||||
}],
|
||||
});
|
||||
|
||||
LEVELS.forEach(function(level) {
|
||||
ipc.on('log-' + level, function() {
|
||||
// first parameter is the event, rest are provided arguments
|
||||
var args = dropFirst(arguments);
|
||||
logger[level].apply(logger, args);
|
||||
LEVELS.forEach((level) => {
|
||||
ipc.on(`log-${level}`, (first, ...rest) => {
|
||||
logger[level](...rest);
|
||||
});
|
||||
});
|
||||
|
||||
ipc.on('fetch-log', (event) => {
|
||||
fetch(logPath).then((data) => {
|
||||
event.sender.send('fetched-log', data);
|
||||
}, (error) => {
|
||||
logger.error(`Problem loading log from disk: ${error.stack}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ipc.on('fetch-log', function(event) {
|
||||
fetch(logPath).then(function(data) {
|
||||
event.sender.send('fetched-log', data);
|
||||
}, function(error) {
|
||||
logger.error('Problem loading log from disk: ' + error.stack);
|
||||
});
|
||||
function cleanupLogs(logPath) {
|
||||
const now = new Date();
|
||||
const earliestDate = new Date(Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth(),
|
||||
now.getUTCDate() - 3
|
||||
));
|
||||
|
||||
return eliminateOutOfDateFiles(logPath, earliestDate).then((remaining) => {
|
||||
const files = _.filter(remaining, file => !file.start && file.end);
|
||||
|
||||
if (!files.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return eliminateOldEntries(files, earliestDate);
|
||||
});
|
||||
}
|
||||
|
||||
function isLineAfterDate(line, date) {
|
||||
if (!line) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
return (new Date(data.time)).getTime() > date.getTime();
|
||||
} catch (e) {
|
||||
console.log('error parsing log line', e.stack, line);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function eliminateOutOfDateFiles(logPath, date) {
|
||||
const files = fs.readdirSync(logPath);
|
||||
const paths = files.map(file => path.join(logPath, file));
|
||||
|
||||
return Promise.all(_.map(
|
||||
paths,
|
||||
target => Promise.all([
|
||||
readFirstLine(target),
|
||||
readLastLines(target, 2),
|
||||
]).then((results) => {
|
||||
const start = results[0];
|
||||
const end = results[1].split('\n');
|
||||
|
||||
const file = {
|
||||
path: target,
|
||||
start: isLineAfterDate(start, date),
|
||||
end: isLineAfterDate(end[end.length - 1], date)
|
||||
|| isLineAfterDate(end[end.length - 2], date),
|
||||
};
|
||||
|
||||
if (!file.start && !file.end) {
|
||||
fs.unlinkSync(file.path);
|
||||
}
|
||||
|
||||
return file;
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
function eliminateOldEntries(files, date) {
|
||||
const earliest = date.getTime();
|
||||
|
||||
return Promise.all(_.map(
|
||||
files,
|
||||
file => fetchLog(file.path).then((lines) => {
|
||||
const recent = _.filter(lines, line => (new Date(line.time)).getTime() >= earliest);
|
||||
const text = _.map(recent, line => JSON.stringify(line)).join('\n');
|
||||
|
||||
return fs.writeFileSync(file.path, `${text}\n`);
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
function getLogger() {
|
||||
if (!logger) {
|
||||
throw new Error('Logger hasn\'t been initialized yet!');
|
||||
|
@ -68,18 +153,19 @@ function getLogger() {
|
|||
}
|
||||
|
||||
function fetchLog(logFile) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
fs.readFile(logFile, { encoding: 'utf8' }, function(err, text) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.readFile(logFile, { encoding: 'utf8' }, (err, text) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
const lines = _.compact(text.split('\n'));
|
||||
const data = _.compact(lines.map(function(line) {
|
||||
const data = _.compact(lines.map((line) => {
|
||||
try {
|
||||
return _.pick(JSON.parse(line), ['level', 'time', 'msg']);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
catch (e) {}
|
||||
}));
|
||||
|
||||
return resolve(data);
|
||||
|
@ -89,19 +175,17 @@ function fetchLog(logFile) {
|
|||
|
||||
function fetch(logPath) {
|
||||
const files = fs.readdirSync(logPath);
|
||||
const paths = files.map(function(file) {
|
||||
return path.join(logPath, file)
|
||||
});
|
||||
const paths = files.map(file => path.join(logPath, file));
|
||||
|
||||
// creating a manual log entry for the final log result
|
||||
var now = new Date();
|
||||
const now = new Date();
|
||||
const fileListEntry = {
|
||||
level: 30, // INFO
|
||||
time: now.toJSON(),
|
||||
msg: 'Loaded this list of log files from logPath: ' + files.join(', '),
|
||||
msg: `Loaded this list of log files from logPath: ${files.join(', ')}`,
|
||||
};
|
||||
|
||||
return Promise.all(paths.map(fetchLog)).then(function(results) {
|
||||
return Promise.all(paths.map(fetchLog)).then((results) => {
|
||||
const data = _.flatten(results);
|
||||
|
||||
data.push(fileListEntry);
|
||||
|
@ -111,18 +195,14 @@ function fetch(logPath) {
|
|||
}
|
||||
|
||||
|
||||
function logAtLevel() {
|
||||
const level = arguments[0];
|
||||
const args = Array.prototype.slice.call(arguments, 1);
|
||||
|
||||
function logAtLevel(level, ...args) {
|
||||
if (logger) {
|
||||
// To avoid [Object object] in our log since console.log handles non-strings smoothly
|
||||
const str = args.map(function(item) {
|
||||
const str = args.map((item) => {
|
||||
if (typeof item !== 'string') {
|
||||
try {
|
||||
return JSON.stringify(item);
|
||||
}
|
||||
catch (e) {
|
||||
} catch (e) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
@ -131,20 +211,16 @@ function logAtLevel() {
|
|||
});
|
||||
logger[level](str.join(' '));
|
||||
} else {
|
||||
console._log.apply(console, consoleArgs);
|
||||
console._log(...args);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
console._log = console.log;
|
||||
console.log = _.partial(logAtLevel, 'info');
|
||||
console._error = console.error;
|
||||
console.error = _.partial(logAtLevel, 'error');
|
||||
console._warn = console.warn;
|
||||
console.warn = _.partial(logAtLevel, 'warn');
|
||||
|
||||
|
||||
module.exports = {
|
||||
initialize,
|
||||
getLogger,
|
||||
};
|
||||
// This blows up using mocha --watch, so we ensure it is run just once
|
||||
if (!console._log) {
|
||||
console._log = console.log;
|
||||
console.log = _.partial(logAtLevel, 'info');
|
||||
console._error = console.error;
|
||||
console.error = _.partial(logAtLevel, 'error');
|
||||
console._warn = console.warn;
|
||||
console.warn = _.partial(logAtLevel, 'warn');
|
||||
}
|
||||
|
|
43
app/menu.js
43
app/menu.js
|
@ -1,18 +1,20 @@
|
|||
function createTemplate(options, messages) {
|
||||
const showDebugLog = options.showDebugLog;
|
||||
const showAbout = options.showAbout;
|
||||
const openReleaseNotes = options.openReleaseNotes;
|
||||
const openNewBugForm = options.openNewBugForm;
|
||||
const openSupportPage = options.openSupportPage;
|
||||
const openForums = options.openForums;
|
||||
const {
|
||||
showDebugLog,
|
||||
showAbout,
|
||||
openReleaseNotes,
|
||||
openNewBugForm,
|
||||
openSupportPage,
|
||||
openForums,
|
||||
} = options;
|
||||
|
||||
let template = [{
|
||||
const template = [{
|
||||
label: messages.mainMenuFile.message,
|
||||
submenu: [
|
||||
{
|
||||
role: 'quit',
|
||||
},
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
label: messages.mainMenuEdit.message,
|
||||
|
@ -43,8 +45,8 @@ function createTemplate(options, messages) {
|
|||
},
|
||||
{
|
||||
role: 'selectall',
|
||||
}
|
||||
]
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: messages.mainMenuView.message,
|
||||
|
@ -77,7 +79,7 @@ function createTemplate(options, messages) {
|
|||
{
|
||||
role: 'toggledevtools',
|
||||
},
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
label: messages.mainMenuWindow.message,
|
||||
|
@ -86,7 +88,7 @@ function createTemplate(options, messages) {
|
|||
{
|
||||
role: 'minimize',
|
||||
},
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
label: messages.mainMenuHelp.message,
|
||||
|
@ -118,7 +120,7 @@ function createTemplate(options, messages) {
|
|||
label: messages.aboutSignalDesktop.message,
|
||||
click: showAbout,
|
||||
},
|
||||
]
|
||||
],
|
||||
}];
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
|
@ -129,8 +131,10 @@ function createTemplate(options, messages) {
|
|||
}
|
||||
|
||||
function updateForMac(template, messages, options) {
|
||||
const showWindow = options.showWindow;
|
||||
const showAbout = options.showAbout;
|
||||
const {
|
||||
showWindow,
|
||||
showAbout,
|
||||
} = options;
|
||||
|
||||
// Remove About item and separator from Help menu, since it's on the first menu
|
||||
template[4].submenu.pop();
|
||||
|
@ -162,13 +166,13 @@ function updateForMac(template, messages, options) {
|
|||
{
|
||||
role: 'quit',
|
||||
},
|
||||
]
|
||||
],
|
||||
});
|
||||
|
||||
// Add to Edit menu
|
||||
template[1].submenu.push(
|
||||
{
|
||||
type: 'separator'
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: messages.speech.message,
|
||||
|
@ -179,11 +183,12 @@ function updateForMac(template, messages, options) {
|
|||
{
|
||||
role: 'stopspeaking',
|
||||
},
|
||||
]
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
// Add to Window menu
|
||||
// Replace Window menu
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
template[3].submenu = [
|
||||
{
|
||||
accelerator: 'CmdOrCtrl+W',
|
||||
|
|
|
@ -1,23 +1,24 @@
|
|||
const electron = require('electron')
|
||||
const path = require('path');
|
||||
|
||||
const app = electron.app;
|
||||
const Menu = electron.Menu;
|
||||
const Tray = electron.Tray;
|
||||
const {
|
||||
app,
|
||||
Menu,
|
||||
Tray,
|
||||
} = require('electron');
|
||||
|
||||
let trayContextMenu = null;
|
||||
let tray = null;
|
||||
|
||||
function createTrayIcon(getMainWindow, messages) {
|
||||
|
||||
// A smaller icon is needed on macOS
|
||||
tray = new Tray(
|
||||
process.platform == "darwin" ?
|
||||
path.join(__dirname, '..', 'images', 'icon_16.png') :
|
||||
path.join(__dirname, '..', 'images', 'icon_256.png'));
|
||||
process.platform === 'darwin' ?
|
||||
path.join(__dirname, '..', 'images', 'icon_16.png') :
|
||||
path.join(__dirname, '..', 'images', 'icon_256.png')
|
||||
);
|
||||
|
||||
tray.toggleWindowVisibility = function () {
|
||||
var mainWindow = getMainWindow();
|
||||
tray.toggleWindowVisibility = () => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isVisible()) {
|
||||
mainWindow.hide();
|
||||
|
@ -33,31 +34,28 @@ function createTrayIcon(getMainWindow, messages) {
|
|||
}
|
||||
}
|
||||
tray.updateContextMenu();
|
||||
}
|
||||
};
|
||||
|
||||
tray.updateContextMenu = function () {
|
||||
|
||||
var mainWindow = getMainWindow();
|
||||
tray.updateContextMenu = () => {
|
||||
const mainWindow = getMainWindow();
|
||||
|
||||
// NOTE: we want to have the show/hide entry available in the tray icon
|
||||
// context menu, since the 'click' event may not work on all platforms.
|
||||
// For details please refer to:
|
||||
// https://github.com/electron/electron/blob/master/docs/api/tray.md.
|
||||
trayContextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
id: 'toggleWindowVisibility',
|
||||
label: messages[mainWindow.isVisible() ? 'hide' : 'show'].message,
|
||||
click: tray.toggleWindowVisibility
|
||||
},
|
||||
{
|
||||
id: 'quit',
|
||||
label: messages.quit.message,
|
||||
click: app.quit.bind(app)
|
||||
}
|
||||
]);
|
||||
trayContextMenu = Menu.buildFromTemplate([{
|
||||
id: 'toggleWindowVisibility',
|
||||
label: messages[mainWindow.isVisible() ? 'hide' : 'show'].message,
|
||||
click: tray.toggleWindowVisibility,
|
||||
},
|
||||
{
|
||||
id: 'quit',
|
||||
label: messages.quit.message,
|
||||
click: app.quit.bind(app),
|
||||
}]);
|
||||
|
||||
tray.setContextMenu(trayContextMenu);
|
||||
}
|
||||
};
|
||||
|
||||
tray.on('click', tray.toggleWindowVisibility);
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const path = require('path');
|
||||
|
||||
const app = require('electron').app;
|
||||
const { app } = require('electron');
|
||||
const ElectronConfig = require('electron-config');
|
||||
|
||||
const config = require('./config');
|
||||
|
@ -10,13 +10,13 @@ const config = require('./config');
|
|||
if (config.has('storageProfile')) {
|
||||
const userData = path.join(
|
||||
app.getPath('appData'),
|
||||
'Signal-' + config.get('storageProfile')
|
||||
`Signal-${config.get('storageProfile')}`
|
||||
);
|
||||
|
||||
app.setPath('userData', userData);
|
||||
}
|
||||
|
||||
console.log('userData: ' + app.getPath('userData'));
|
||||
console.log(`userData: ${app.getPath('userData')}`);
|
||||
|
||||
// this needs to be below our update to the appData path
|
||||
const userConfig = new ElectronConfig();
|
||||
|
|
|
@ -10,5 +10,5 @@ function shouldQuit() {
|
|||
|
||||
module.exports = {
|
||||
shouldQuit,
|
||||
markShouldQuit
|
||||
markShouldQuit,
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue