374 строки
11 KiB
JavaScript
374 строки
11 KiB
JavaScript
const fs = require('fs');
|
|
const ical = require('ical-toolkit');
|
|
const ghpages = require('gh-pages');
|
|
/* Importing as suggested on https://www.npmjs.com/package/node-fetch */
|
|
const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
|
|
|
|
const PUBLISHED_URL = 'https://mozilla.github.io/layout-triage';
|
|
const DIST_DIR = 'dist';
|
|
const CONFIG_FILE = 'config.json';
|
|
const HISTORY_FILE = 'history.json';
|
|
const TRIAGERS_KEY = 'triagers';
|
|
const ICAL_FILE = 'layout-triage.ics';
|
|
const INDENT = ' ';
|
|
const DUTY_START_DATES_KEY = 'duty-start-dates';
|
|
const CYCLE_LENGTH_DAYS = 7;
|
|
const DAY_TO_MS = 24 * 60 * 60 * 1000;
|
|
const CYCLE_LENGTH_MS = CYCLE_LENGTH_DAYS * DAY_TO_MS;
|
|
|
|
/**
|
|
* Return the parsed results from the config file. Reads file synchronously.
|
|
*/
|
|
function readConfig() {
|
|
return JSON.parse(fs.readFileSync(CONFIG_FILE));
|
|
}
|
|
|
|
/**
|
|
* Write the given JSON object to the history file. Writes synchronously.
|
|
* @param {*} json
|
|
*/
|
|
function writeToHistory(json) {
|
|
const data = JSON.stringify(json, undefined, INDENT);
|
|
fs.writeFileSync(`${DIST_DIR}/${HISTORY_FILE}`, data);
|
|
}
|
|
|
|
/**
|
|
* Given a date, return the date of the Monday preceding it.
|
|
* @param {Date} date
|
|
* @returns
|
|
*/
|
|
function getLastMonday(date) {
|
|
const day = date.getDay() || 7;
|
|
|
|
let newDate = new Date(date);
|
|
if (day !== 1) {
|
|
newDate.setHours(-24 * (day - 1));
|
|
}
|
|
|
|
return newDate;
|
|
}
|
|
|
|
/**
|
|
* Given a date, return the date of the Monday following it.
|
|
*/
|
|
function getNextMonday(date) {
|
|
let newDate = getLastMonday(date);
|
|
newDate.setHours(24 * 7);
|
|
return newDate;
|
|
}
|
|
|
|
/**
|
|
* Formats a date as YYYY-MM-DD.
|
|
*/
|
|
function createDateString(date) {
|
|
return date.toISOString().replace(/T.*$/, '');
|
|
}
|
|
|
|
function appendDutyCycle({ component, date, triagerName, triagerData }) {
|
|
const filePath = `${DIST_DIR}/${component}.json`;
|
|
if (!fs.existsSync(filePath)) {
|
|
fs.writeFileSync(filePath, '{"triagers":{}, "duty-start-dates":{}}');
|
|
}
|
|
|
|
let data = fs.readFileSync(filePath);
|
|
const calendar = JSON.parse(data);
|
|
const triagers = calendar[TRIAGERS_KEY];
|
|
const dutyStartDates = calendar[DUTY_START_DATES_KEY];
|
|
if (!dutyStartDates || !triagers) {
|
|
throw `\nFATAL ERROR: Invalid data in calendar ${component}.json`;
|
|
}
|
|
|
|
if (!triagers[triagerName]) {
|
|
triagers[triagerName] = triagerData;
|
|
}
|
|
|
|
dutyStartDates[date] = triagerName;
|
|
|
|
data = JSON.stringify(calendar, undefined, INDENT);
|
|
fs.writeFileSync(filePath, data);
|
|
}
|
|
|
|
/**
|
|
* Given an array, select n random items from the array,
|
|
*
|
|
* @param {} arr The array from which to select items.
|
|
* @param {*} n The number of items to select.
|
|
*/
|
|
function selectRandom(arr, n) {
|
|
const result = [];
|
|
const taken = [];
|
|
let len = arr.length;
|
|
|
|
if (n > len) {
|
|
throw `FATAL ERROR: Cannot select ${n} items from array with length ${arr.length}`;
|
|
}
|
|
|
|
while (n--) {
|
|
let idx = Math.floor(Math.random() * len);
|
|
result[n] = arr[idx in taken ? taken[idx] : idx];
|
|
taken[idx] = --len in taken ? taken[len] : len;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Given a duty cycle history object, return the most recent cycle.
|
|
*
|
|
* @param {*} params
|
|
* @param {*} params.dutyCycleHistory The duty cycle history as formatted in the history file.
|
|
*/
|
|
function getLastDutyCycle({ dutyCycleHistory }) {
|
|
const dutyDates = Object.keys(dutyCycleHistory).sort();
|
|
if (dutyDates.length < 1) {
|
|
return {};
|
|
}
|
|
|
|
const lastDutyDate = dutyDates.slice(-1)[0];
|
|
if (!dutyCycleHistory[lastDutyDate]) {
|
|
throw `\nFATAL ERROR: Invalid data in history file!`;
|
|
}
|
|
|
|
const lastTriagePair = Object.keys(dutyCycleHistory[lastDutyDate]);
|
|
return {
|
|
lastDutyDate,
|
|
lastTriagePair
|
|
}
|
|
}
|
|
|
|
function generateBugzillaUrl(componentNames) {
|
|
const prefix = 'https://bugzilla.mozilla.org/buglist.cgi?' +
|
|
'bug_severity=--' +
|
|
'&f1=short_desc' +
|
|
'&bug_type=defect' +
|
|
'&o1=notsubstring' +
|
|
'&resolution=---' +
|
|
'&classification=Client%20Software' +
|
|
'&classification=Developer%20Infrastructure' +
|
|
'&classification=Components' +
|
|
'&classification=Server%20Software' +
|
|
'&classification=Other' +
|
|
'&query_format=advanced' +
|
|
'&chfield=%5BBug%20creation%5D' +
|
|
'&chfieldfrom=-60d' +
|
|
'&v1=%5Bmeta%5D' +
|
|
'&product=Core';
|
|
return prefix + '&' + componentNames.map(name => `component=${encodeURIComponent(name)}`).join('&')
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {*} params
|
|
* @param {*} params.dutyCycleHistory
|
|
*/
|
|
function generateIcsFile({ dutyCycleHistory, components }) {
|
|
const builder = ical.createIcsFileBuilder();
|
|
|
|
builder.calname = 'Layout Triage';
|
|
builder.timezone = 'America/Los_Angeles';
|
|
builder.tzid = 'America/Los_Angeles';
|
|
builder.additionalTags = {
|
|
'REFRESH-INTERVAL': 'VALUE=DURATION:P1H',
|
|
'X-WR-CALDESC': 'Layout Triage'
|
|
};
|
|
|
|
for (let dutyCycleDate in dutyCycleHistory) {
|
|
const dutyCycle = dutyCycleHistory[dutyCycleDate];
|
|
const triagerNames = Object.keys(dutyCycle);
|
|
const dutyCycleDateMs = new Date(dutyCycleDate).getTime();
|
|
|
|
const triager0Components = Array.prototype.concat.apply([], dutyCycle[triagerNames[0]].map(component => components[component]));
|
|
const triager1Components = Array.prototype.concat.apply([], dutyCycle[triagerNames[1]].map(component => components[component]));
|
|
|
|
builder.events.push({
|
|
start: new Date(dutyCycleDateMs),
|
|
end: new Date(dutyCycleDateMs + CYCLE_LENGTH_MS),
|
|
summary: `Triage Duty: ${triagerNames.join(', ')}`,
|
|
allDay: true,
|
|
transp: 'TRANSPARENT',
|
|
description: `<strong>${triagerNames[0]}:</strong> ` +
|
|
`(<a href="${generateBugzillaUrl(triager0Components)}">Bugzilla Query</a>)` +
|
|
`<ul>${triager0Components.map(c => `<li>${c}</li>`).join('')}</ul><br>` +
|
|
`<strong>${triagerNames[1]}:</strong> ` +
|
|
`(<a href="${generateBugzillaUrl(triager1Components)}">Bugzilla Query</a>)` +
|
|
`<ul>${triager1Components.map(c => `<li>${c}</li>`).join('')}</ul>`
|
|
});
|
|
}
|
|
|
|
const data = builder.toString();
|
|
fs.writeFileSync(`${DIST_DIR}/${ICAL_FILE}`, data);
|
|
}
|
|
|
|
function generateDutyCycle({ dutyCycleHistory, triagers, components }) {
|
|
let { lastDutyDate, lastTriagePair } = getLastDutyCycle({ dutyCycleHistory })
|
|
let lastTriagerIdx = -1;
|
|
const triagerNames = Object.keys(triagers);
|
|
const componentNames = Object.keys(components);
|
|
|
|
if (!lastDutyDate || !Array.isArray(lastTriagePair) || lastTriagePair.length !== 2) {
|
|
console.warn('No existing duty cycle history. Generating first cycle.');
|
|
lastDutyDate = createDateString(getLastMonday(new Date()));
|
|
} else {
|
|
lastTriagerIdx = triagerNames.indexOf(lastTriagePair[1]);
|
|
if (lastTriagerIdx === -1) {
|
|
console.warn(`Unable to find triager named ${lastTriagePair[1]} in config. Starting over from first triager.`);
|
|
}
|
|
}
|
|
|
|
const nextTriagerIdx = (lastTriagerIdx + 1) % triagerNames.length;
|
|
const nextDutyDateMS = new Date(lastDutyDate).getTime() + CYCLE_LENGTH_MS;
|
|
const nextTriagePair = [triagerNames[nextTriagerIdx], triagerNames[(nextTriagerIdx +1 ) % triagerNames.length]];
|
|
const nextDutyDate = createDateString(new Date(nextDutyDateMS));
|
|
const firstComponentSet = selectRandom(componentNames, Math.floor(componentNames.length / 2));
|
|
const secondComponentSet = componentNames.filter(c => firstComponentSet.indexOf(c) === -1);
|
|
const dutyCycle = {};
|
|
dutyCycle[nextTriagePair[0]] = firstComponentSet;
|
|
dutyCycle[nextTriagePair[1]] = secondComponentSet;
|
|
|
|
return {
|
|
date: nextDutyDate,
|
|
dutyCycle
|
|
};
|
|
}
|
|
|
|
function runUpdate() {
|
|
const { triagers, components } = readConfig();
|
|
const { dutyCycleHistory } = JSON.parse(fs.readFileSync(`${DIST_DIR}/${HISTORY_FILE}`));
|
|
const { date, dutyCycle } = generateDutyCycle({ dutyCycleHistory, triagers, components });
|
|
|
|
function updateJSONCalendars() {
|
|
const newDutyCycleTriagers = Object.keys(dutyCycle);
|
|
newDutyCycleTriagers.forEach(triagerName => {
|
|
const components = dutyCycle[triagerName];
|
|
components.forEach(component => {
|
|
appendDutyCycle({ component, date, triagerName, triagerData: triagers[triagerName] });
|
|
});
|
|
});
|
|
}
|
|
|
|
dutyCycleHistory[date] = dutyCycle;
|
|
|
|
updateJSONCalendars();
|
|
writeToHistory({ dutyCycleHistory });
|
|
generateIcsFile({ dutyCycleHistory, components });
|
|
}
|
|
|
|
/**
|
|
* Reset all existing data, leaving one duty cycle in the history to serve as a
|
|
* seed for the next cycle.
|
|
*/
|
|
function runReset() {
|
|
const { components } = readConfig();
|
|
const resetData = {};
|
|
resetData[TRIAGERS_KEY] = {};
|
|
resetData[DUTY_START_DATES_KEY] = {};
|
|
const resetDataString = JSON.stringify(resetData, undefined, INDENT);
|
|
|
|
Object.keys(components).forEach(component => {
|
|
const filePath = `${DIST_DIR}/${component}.json`;
|
|
fs.writeFileSync(filePath, resetDataString);
|
|
});
|
|
|
|
writeToHistory({ dutyCycleHistory: {} });
|
|
generateIcsFile({ dutyCycleHistory: {} });
|
|
}
|
|
|
|
function runPublish() {
|
|
ghpages.publish(DIST_DIR, function (err) {
|
|
if (err) {
|
|
console.error('There was an error during publishing:');
|
|
console.error(err.message);
|
|
} else {
|
|
console.log('Publish to GitHub was successful.');
|
|
}
|
|
});
|
|
}
|
|
|
|
async function runInit() {
|
|
const { components } = readConfig();
|
|
|
|
if (fs.existsSync(DIST_DIR)) {
|
|
console.error('Cannot initialize while dist/ directory exists.');
|
|
return;
|
|
}
|
|
fs.mkdirSync(DIST_DIR);
|
|
|
|
const filenames = [
|
|
'history.json',
|
|
...Object.keys(components).map(component => `${component}.json`)
|
|
];
|
|
|
|
for (let filename of filenames) {
|
|
const url = `${PUBLISHED_URL}/${filename}`;
|
|
const res = await fetch(url);
|
|
|
|
if (!res.ok) {
|
|
console.error(`Error fetching ${url}`);
|
|
continue;
|
|
}
|
|
|
|
fs.writeFileSync(`${DIST_DIR}/${filename}`, await res.text());
|
|
}
|
|
}
|
|
|
|
function runClear() {
|
|
const { triagers, components } = readConfig();
|
|
const { dutyCycleHistory } = JSON.parse(fs.readFileSync(`${DIST_DIR}/${HISTORY_FILE}`));
|
|
const thisWeek = createDateString(getNextMonday(new Date()));
|
|
|
|
Object.keys(dutyCycleHistory)
|
|
.filter(d => d > thisWeek)
|
|
.forEach(d => delete dutyCycleHistory[d]);
|
|
|
|
for (let component in components) {
|
|
const filePath = `${DIST_DIR}/${component}.json`;
|
|
if (!fs.existsSync(filePath)) {
|
|
continue;
|
|
}
|
|
|
|
let calendar = JSON.parse(fs.readFileSync(filePath));
|
|
Object.keys(calendar[DUTY_START_DATES_KEY])
|
|
.filter(d => d > thisWeek)
|
|
.forEach(d => delete calendar[DUTY_START_DATES_KEY][d]);
|
|
|
|
fs.writeFileSync(filePath, JSON.stringify(calendar, undefined, INDENT));
|
|
}
|
|
|
|
writeToHistory({ dutyCycleHistory });
|
|
generateIcsFile({ dutyCycleHistory, components });
|
|
}
|
|
|
|
let args = process.argv.slice(2);
|
|
let command = args.shift();
|
|
|
|
if (command != 'init' && !fs.existsSync(DIST_DIR)) {
|
|
fs.mkdirSync(DIST_DIR);
|
|
}
|
|
|
|
switch (command) {
|
|
case 'update': {
|
|
runUpdate();
|
|
break;
|
|
}
|
|
|
|
case 'clear': {
|
|
runClear();
|
|
break;
|
|
}
|
|
|
|
case 'reset': {
|
|
runReset();
|
|
break;
|
|
}
|
|
|
|
case 'publish': {
|
|
runPublish();
|
|
break;
|
|
}
|
|
|
|
case 'init': {
|
|
runInit();
|
|
break;
|
|
}
|
|
}
|