${match[1]}: ${match[2]}
\n`; + } + else { + content += `${p}
\n`; + } + pid += 1; + } + content += (options.markupEnd || '';
+ for (const p of divs.map(d => d.paragraphs).flat().flat()) {
+ const match = p.match(/^(.*):\s*(.*)$/);
+ if (match) {
+ if (last && match[1] === last) {
+ content += `
\n … ${match[2]}`;
+ }
+ else {
+ content += `
${match[1]}: ${match[2]}`; + } + last = match[1]; + } + else { + content += `
\n ${p}`; + } + } + } + + return content; +} diff --git a/tools/list-chairs.mjs b/tools/list-chairs.mjs new file mode 100644 index 0000000..d48b374 --- /dev/null +++ b/tools/list-chairs.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +/** + * This tool reports information about breakout session chairs. + * + * To run the tool: + * + * node tools/list-chairs.mjs + */ + +import { getEnvKey } from './lib/envkeys.mjs'; +import { fetchProject } from './lib/project.mjs' +import { validateGrid } from './lib/validate.mjs'; +import { authenticate } from './lib/calendar.mjs'; +import puppeteer from 'puppeteer'; + +async function main() { + const PROJECT_OWNER = await getEnvKey('PROJECT_OWNER'); + const PROJECT_NUMBER = await getEnvKey('PROJECT_NUMBER'); + const CHAIR_W3CID = await getEnvKey('CHAIR_W3CID', {}, true); + const W3C_LOGIN = await getEnvKey('W3C_LOGIN'); + const W3C_PASSWORD = await getEnvKey('W3C_PASSWORD'); + console.log(); + console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER}...`); + const project = await fetchProject(PROJECT_OWNER, PROJECT_NUMBER); + if (!project) { + throw new Error(`Project ${PROJECT_OWNER}/${PROJECT_NUMBER} could not be retrieved`); + } + project.chairsToW3CID = CHAIR_W3CID; + const errors = await validateGrid(project) + console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER}... done`); + + const sessions = project.sessions.filter(session => session.chairs); + sessions.sort((s1, s2) => s1.number - s2.number); + + const chairs = sessions + .map(session => session.chairs) + .flat() + .filter((chair, index, list) => list.findIndex(c => + c.name === chair.name || c.login === chair.login || c.w3cId === chair.w3cId) === index); + + function formatChair(chair) { + const parts = []; + if (chair.name && chair.email) { + parts.push(`${chair.name} <${chair.email}>`); + } + else if (chair.name) { + parts.push(`${chair.name}`); + } + if (chair.login) { + parts.push(`https://github.com/${chair.login}`); + } + if (chair.w3cId) { + parts.push(`https://www.w3.org/users/${chair.w3cId}`); + } + return parts.join(' '); + } + + if (W3C_LOGIN && W3C_PASSWORD) { + console.log(); + console.log('Retrieving chair emails...'); + const browser = await puppeteer.launch({ headless: true }); + try { + for (const chair of chairs) { + if (!chair.w3cId) { + continue; + } + const page = await browser.newPage(); + const url = `https://www.w3.org/users/${chair.w3cId}/`; + try { + await page.goto(url); + await authenticate(page, W3C_LOGIN, W3C_PASSWORD, url); + chair.email = await page.evaluate(() => { + const el = document.querySelector('.card--user a[href^=mailto]'); + return el.textContent.trim(); + }); + } + finally { + page.close(); + } + } + } + finally { + browser.close(); + } + console.log('Retrieving chair emails... done'); + } + + console.log(); + console.log('All chairs'); + console.log('----------'); + for (const chair of chairs) { + console.log(formatChair(chair)); + } + + console.log(); + console.log('All emails'); + console.log('----------'); + const emails = chairs + .filter(chair => chair.email) + .map(chair => `${chair.name} <${chair.email}>`) + console.log(emails.join(', ')); + + console.log(); + console.log('Per session'); + console.log('-----------'); + for (const session of sessions) { + console.log(`#${session.number} - ${session.title}`); + for (const chair of session.chairs) { + console.log(formatChair(chair)); + } + console.log(); + } +} + +main() + .catch(err => { + console.log(`Something went wrong: ${err.message}`); + throw err; + }); \ No newline at end of file diff --git a/tools/minutes-to-w3c.mjs b/tools/minutes-to-w3c.mjs new file mode 100644 index 0000000..f46cfab --- /dev/null +++ b/tools/minutes-to-w3c.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +/** + * @@ + * + * To run the tool: + * + * node tools/minutes-to-w3c.mjs [sessionNumber] + * + * where [sessionNumber] is the number of the issue to process (e.g. 15). + * Leave empty to add minute links to all sessions. + */ + +import { getEnvKey } from './lib/envkeys.mjs'; +import { fetchProject } from './lib/project.mjs' +import { validateSession } from './lib/validate.mjs'; +import puppeteer from 'puppeteer'; + +async function main(number) { + const PROJECT_OWNER = await getEnvKey('PROJECT_OWNER'); + const PROJECT_NUMBER = await getEnvKey('PROJECT_NUMBER'); + console.log(); + console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER}...`); + const project = await fetchProject(PROJECT_OWNER, PROJECT_NUMBER); + if (!project) { + throw new Error(`Project ${PROJECT_OWNER}/${PROJECT_NUMBER} could not be retrieved`); + } + let sessions = project.sessions.filter(s => s.slot && s.room && + (!number || s.number === number)); + sessions.sort((s1, s2) => s1.number - s2.number); + if (number) { + if (sessions.length === 0) { + throw new Error(`Session ${number} not found (or did not take place) in project ${PROJECT_OWNER}/${PROJECT_NUMBER}`); + } + } + else { + console.log(`- found ${sessions.length} sessions assigned to a slot and room`); + } + sessions = await Promise.all(sessions.map(async session => { + const sessionErrors = (await validateSession(session.number, project)) + .filter(error => error.severity === 'error'); + return session; + })); + sessions = sessions.filter(s => !!s); + if (number) { + if (sessions.length === 0) { + throw new Error(`Session ${number} contains errors that need fixing`); + } + else if (sessions[0].description.materials.minutes) { + console.log("Session " + number + ": " + sessions[0].description.materials.minutes); + return; + } + } + else { + for (const session of sessions.filter(s => s.description.materials.minutes)) { + const url = session.description.materials.minutes; + if (url.match(/w3\.org|\@\@/)) { + console.log("Skipping " + session.number + ": " + url); + } else if (url.match(/docs\.google\.com/)) { + console.log(session.number + ": " + session.description.materials.minutes); + (async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + await page.goto(url); + await page.pdf({ + path: session.number + '-minutes.pdf', + }); + await browser.close(); + })(); + } else { + console.log("Manually get: " + session.number + ": " + session.description.materials.minutes); + } + } + } + console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER} and session(s)... done`); +} + + +// Read session number from command-line +if (process.argv[2] && !process.argv[2].match(/^(\d+|all)$/)) { + console.log('First parameter should be a session number or "all"'); + process.exit(1); +} +const sessionNumber = process.argv[2]?.match(/^\d+$/) ? parseInt(process.argv[2], 10) : undefined; + +main(sessionNumber) + .catch(err => { + console.log(`Something went wrong: ${err.message}`); + throw err; + }); diff --git a/tools/setup-irc.mjs b/tools/setup-irc.mjs new file mode 100644 index 0000000..c5cd24e --- /dev/null +++ b/tools/setup-irc.mjs @@ -0,0 +1,441 @@ +#!/usr/bin/env node +/** + * This tool initializes IRC channels that will be used for breakout sessions. + * + * To run the tool: + * + * node tools/setup-irc.mjs [sessionNumber or "all"] [commands] [dismiss] + * + * where [sessionNumber or "all"] is the session issue number or "all" to + * initialize IRC channels for all valid sessions. + * + * Set [commands] to "commands" to only output the IRC commands to run without + * actually running them. + * + * Set [dismiss] to "dismiss" to make bots draft minutes and leave the channel. + * + * The tool runs IRC commands one after the other to avoid getting kicked out + * of the IRC server. It allows checks that IRC bots return the appropriate + * responses. + */ + +import { getEnvKey } from './lib/envkeys.mjs'; +import { fetchProject } from './lib/project.mjs' +import { validateSession } from './lib/validate.mjs'; +import { todoStrings } from './lib/todostrings.mjs'; +import irc from 'irc'; + +const botName = 'tpac-breakout-bot'; +const timeout = 60 * 1000; + +/** + * Helper function to generate a shortname from the session's title + */ +function getChannel(session) { + return session.description.shortname; +} + + +/** + * Helper function to make the code wait for a specific IRC command from the + * IRC server, typically to check that a command we sent was properly executed. + * + * Note the function will timeout after some time. The timeout is meant to + * avoid getting stuck in an infinite loop when a bot becomes unresponsive. + */ +const pendingIRCMessage = { + what: {}, + promise: null, + resolve: null +}; +async function waitForIRCMessage(what) { + pendingIRCMessage.what = what; + pendingIRCMessage.promise = new Promise((resolve, reject) => { + pendingIRCMessage.resolve = resolve; + }); + const timeoutPromise = new Promise((resolve, reject) => { + setTimeout(reject, timeout, 'timeout'); + }); + return Promise.race([pendingIRCMessage.promise, timeoutPromise]); +} + +/** + * Main function + */ +async function main({ number, onlyCommands, dismissBots } = {}) { + const PROJECT_OWNER = await getEnvKey('PROJECT_OWNER'); + const PROJECT_NUMBER = await getEnvKey('PROJECT_NUMBER'); + const CHAIR_W3CID = await getEnvKey('CHAIR_W3CID', {}, true); + console.log(); + console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER}...`); + const project = await fetchProject(PROJECT_OWNER, PROJECT_NUMBER); + if (!project) { + throw new Error(`Project ${PROJECT_OWNER}/${PROJECT_NUMBER} could not be retrieved`); + } + project.chairsToW3CID = CHAIR_W3CID; + let sessions = project.sessions.filter(s => s.slot && + (!number || s.number === number)); + sessions.sort((s1, s2) => s1.number - s2.number); + if (number) { + if (sessions.length === 0) { + throw new Error(`Session ${number} not found in project ${PROJECT_OWNER}/${PROJECT_NUMBER}`); + } + else if (!sessions[0].slot) { + throw new Error(`Session ${number} not assigned to a slot in project ${PROJECT_OWNER}/${PROJECT_NUMBER}`); + } + } + else { + console.log(`- found ${sessions.length} sessions assigned to slots: ${sessions.map(s => s.number).join(', ')}`); + } + sessions = await Promise.all(sessions.map(async session => { + const sessionErrors = (await validateSession(session.number, project)) + .filter(error => error.severity === 'error'); + if (sessionErrors.length > 0) { + return null; + } + return session; + })); + sessions = sessions.filter(s => !!s); + if (number) { + if (sessions.length === 0) { + throw new Error(`Session ${number} contains errors that need fixing`); + } + } + else { + console.log(`- found ${sessions.length} valid sessions among them: ${sessions.map(s => s.number).join(', ')}`); + } + console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER} and session(s)... done`); + + console.log('Compute IRC channels...'); + const channels = {}; + for (const session of sessions) { + const channel = getChannel(session); + if (!channels[channel]) { + channels[channel] = []; + } + channels[channel].push(session); + channels[channel].sort((s1, s2) => { + const slot1 = project.slots.findIndex(slot => slot.name === s1.slot); + const slot2 = project.slots.findIndex(slot => slot.name === s2.slot); + return slot1 - slot2; + }); + } + sessions = Object.values(channels).map(sessions => sessions[0]); + console.log(`- found ${Object.keys(channels).length} different IRC channels`); + console.log('Compute IRC channels... done'); + + console.log(); + console.log('Connect to W3C IRC server...'); + const bot = onlyCommands ? + undefined : + new irc.Client('irc.w3.org', botName, { + channels: [] + }); + + const connection = { + established: null, + resolve: null, + reject: null + }; + connection.established = new Promise((resolve, reject) => { + connection.resolve = resolve; + connection.reject = reject; + }); + if (bot) { + bot.addListener('registered', msg => { + console.log(`- registered message: ${msg.command}`); + connection.resolve(); + }); + } + else { + console.log(`- commands only, no connection needed`); + connection.resolve(); + } + await connection.established; + console.log('Connect to W3C IRC server... done'); + + if (bot) { + // Only useful when debugging the code + /*bot.addListener('raw', msg => { + console.log(JSON.stringify({ + nick: msg.nick, + command: msg.command, + commandType: msg.commandType, + raw: msg.rawCommand, + args: msg.args + }, null, 2)); + });*/ + + // Listen to the JOIN messages that tell us when our bot or the bots we've + // invited have joined the IRC channel. + bot.addListener('join', (channel, nick, message) => { + if (pendingIRCMessage.what.command === 'join' && + pendingIRCMessage.what.channel === channel && + pendingIRCMessage.what.nick === nick) { + pendingIRCMessage.resolve(); + } + }); + + // Listen to the list of users in the channels we joined + bot.addListener('names', (channel, nicks) => { + if (pendingIRCMessage.what.command === 'names' && + pendingIRCMessage.what.channel === channel) { + pendingIRCMessage.resolve(Object.keys(nicks)); + } + }); + + // Listen to the MESSAGE messages that contain bot replies to our commands. + bot.addListener('message', (nick, channel, text, message) => { + if (pendingIRCMessage.what.command === 'message' && + (pendingIRCMessage.what.channel === channel || channel === botName) && + pendingIRCMessage.what.nick === nick && + text.startsWith(pendingIRCMessage.what.message)) { + pendingIRCMessage.resolve(); + } + }); + + // Listen to the TOPIC message that should tell us that we managed to set + // the topic as planned. + bot.addListener('topic', (channel, topic, nick, message) => { + if (pendingIRCMessage.what.command === 'topic' && + pendingIRCMessage.what.channel === channel && + pendingIRCMessage.what.nick === nick) { + pendingIRCMessage.resolve(); + } + }); + + // Listen to PART messages to tell when our bot or other bots leave the + // channel. + bot.addListener('part', (channel, nick) => { + if (pendingIRCMessage.what.command === 'part' && + pendingIRCMessage.what.channel === channel && + pendingIRCMessage.what.nick === nick) { + pendingIRCMessage.resolve(); + } + }); + + // Errors are returned when a bot gets invited to a channel where it + // already is, and when disconnecting from the server. Both cases are fine, + // let's trap them. + bot.addListener('error', err => { + if (err.command === 'err_useronchannel' && + pendingIRCMessage.what.command === 'join' && + pendingIRCMessage.what.channel === err.args[2] && + pendingIRCMessage.what.nick === err.args[1]) { + pendingIRCMessage.resolve(); + } + else if (err.command === 'ERROR' && + err.args[0] === '"node-irc says goodbye"') { + console.log('- disconnected from IRC server'); + } + else { + throw err; + } + }); + } + + function joinChannel(session) { + const channel = getChannel(session); + console.log(`/join ${channel}`); + if (!onlyCommands) { + bot.join(channel); + return waitForIRCMessage({ command: 'names', channel, nick: botName }); + } + } + + function inviteBot(session, name) { + const channel = getChannel(session); + console.log(`/invite ${name} ${channel}`); + if (!onlyCommands) { + bot.send('INVITE', name, channel); + return waitForIRCMessage({ command: 'join', channel, nick: name }); + } + } + + function leaveChannel(session) { + const channel = getChannel(session); + if (!onlyCommands) { + bot.part(channel); + return waitForIRCMessage({ command: 'part', channel, nick: botName }); + } + } + + function setTopic(session) { + const channel = getChannel(session); + const room = project.rooms.find(r => r.name === session.room); + const roomLabel = room ? `- ${room.label} ` : ''; + const topic = `TPAC breakout: ${session.title} ${roomLabel}- ${session.slot}`; + console.log(`/topic ${channel} ${topic}`); + if (!onlyCommands) { + bot.send('TOPIC', channel, topic); + return waitForIRCMessage({ command: 'topic', channel, nick: botName }); + } + } + + async function setupRRSAgent(session) { + const channel = getChannel(session); + await say(channel, { + to: 'RRSAgent', + message: `do not leave`, + reply: `ok, ${botName}; I will stay here even if the channel goes idle` + }); + + await say(channel, { + to: 'RRSAgent', + message: `make logs ${session.description.attendance === 'restricted' ? 'member' : 'public'}`, + reply: `I have made the request, ${botName}` + }); + + await say(channel, `Meeting: ${session.title}`); + await say(channel, `Chair: ${session.chairs.map(c => c.name).join(', ')}`); + if (session.description.materials.agenda && + !todoStrings.includes(session.description.materials.agenda)) { + await say(channel, `Agenda: ${session.description.materials.agenda}`); + } + else { + await say(channel, `Agenda: https://github.com/${session.repository}/issues/${session.number}`); + } + if (session.description.materials.slides && + !todoStrings.includes(session.description.materials.slides)) { + await say(channel, `Slideset: ${session.description.materials.slides}`); + } + } + + async function setupZakim(session) { + const channel = getChannel(session); + await say(channel, { + to: 'Zakim', + message: 'clear agenda', + reply: 'agenda cleared' + }); + await say(channel, { + to: 'Zakim', + message: 'agenda+ Pick a scribe', + reply: 'agendum 1 added' + }); + await say(channel, { + to: 'Zakim', + message: 'agenda+ Reminders: code of conduct, health policies, recorded session policy', + reply: 'agendum 2 added' + }); + await say(channel, { + to: 'Zakim', + message: 'agenda+ Goal of this session', + reply: 'agendum 3 added' + }); + await say(channel, { + to: 'Zakim', + message: 'agenda+ Discussion', + reply: 'agendum 4 added' + }); + await say(channel, { + to: 'Zakim', + message: 'agenda+ Next steps / where discussion continues', + reply: 'agendum 5 added' + }); + } + + async function draftMinutes(session, channelUsers) { + const channel = getChannel(session); + if (channelUsers.includes('Zakim')) { + await say(channel, `Zakim, bye`); + } + if (channelUsers.includes('RRSAgent')) { + // Should have been already done in theory, but worth re-doing just in + // case, especially since RRSAgent won't leave a channel until some + // access level has been specified. + await say(channel, { + to: 'RRSAgent', + message: `make logs ${session.description.attendance === 'restricted' ? 'member' : 'public'}`, + reply: `I have made the request, ${botName}` + }); + await say(channel, { + to: 'RRSAgent', + message: 'draft minutes', + reply: 'I have made the request to generate' + }); + await say(channel, `RRSAgent, bye`); + } + } + + // Helper function to send a message to a channel. The function waits for a + // reply if one is expected. + function say(channel, msg) { + const message = msg?.to ? + `${msg.to}, ${msg.message}` : + (msg?.message ? msg.message : msg); + console.log(`/msg ${channel} ${message}`); + if (!onlyCommands) { + bot.say(channel, message); + if (msg?.reply) { + return waitForIRCMessage({ + command: 'message', channel, + nick: msg.to, message: msg.reply + }); + } + } + } + + const errors = []; + for (const session of sessions) { + console.log(); + console.log(`session ${session.number}`); + console.log('-----'); + try { + const channelUsers = await joinChannel(session); + if (dismissBots) { + await draftMinutes(session, channelUsers); + } + else { + await setTopic(session); + if (!channelUsers.includes('RRSAgent')) { + await inviteBot(session, 'RRSAgent'); + } + await setupRRSAgent(session); + if (!channelUsers.includes('Zakim')) { + await inviteBot(session, 'Zakim'); + } + await setupZakim(session); + await leaveChannel(session); + } + } + catch (err) { + errors.push(`- ${session.number}: ${err.message}`); + console.log(`- An error occurred: ${err.message}`); + } + console.log('-----'); + } + + if (!onlyCommands) { + return new Promise((resolve, reject) => { + console.log('Disconnect from IRC server...'); + bot.disconnect(_ => { + console.log('Disconnect from IRC server... done'); + if (errors.length > 0) { + reject(new Error(errors.join('\n'))); + } + else { + resolve(); + } + }); + }); + } +} + +// Read session number from command-line +if (!process.argv[2] || !process.argv[2].match(/^(\d+|all)$/)) { + console.log('Command needs to receive a session number (e.g., 15) or "all" as first parameter'); + process.exit(1); +} +const number = process.argv[2] === 'all' ? undefined : parseInt(process.argv[2], 10); + +// Command only? +const onlyCommands = process.argv[3] === 'commands'; +const dismissBots = process.argv[4] === 'dismiss'; + +main({ number, onlyCommands, dismissBots }) + .then(_ => process.exit(0)) + .catch(err => { + console.error(`Something went wrong:\n${err.message}`); + process.exit(1); + }); \ No newline at end of file diff --git a/tools/suggest-grid.mjs b/tools/suggest-grid.mjs new file mode 100644 index 0000000..fed85c9 --- /dev/null +++ b/tools/suggest-grid.mjs @@ -0,0 +1,771 @@ +#!/usr/bin/env node +/** + * This tool suggests a grid that could perhaps work given known constraints. + * + * To run the tool: + * + * node tools/suggest-grid.mjs [preservelist or all or none] [exceptlist or none] [apply] [seed] + * + * where [preservelist or all] is a comma-separated (no spaces) list of session + * numbers whose assigned slots and rooms must be preserved. Or "all" to + * preserve all slots and rooms that have already been assigned. Or "none" not + * to preserve anything. + * + * [exceptlist or none] only makes sense when the preserve list is "all" and + * allows to specify a comma-separated (no spaces) list of session numbers whose + * assigned slots and rooms are to be discarded. Or "none" to say "no exception, + * preserve info in all sessions". + * + * [apply] is "apply" if you want to apply the suggested grid on GitHub, or + * a link to a changes file if you want to test changes to the suggested grid + * before it gets validated and saved as an HTML page. The changes file must be + * a file where each row starts with a session number, followed by a space, + * followed by either a slot start time or a slot number or a room name. If slot + * was specified, it may be followed by another space, followed by a room name. + * (Room name cannot be specified before the slot). + * + * + * [seed] is the seed string to shuffle the array of sessions. + * + * Assumptions: + * - All rooms are of equal quality + * - Some slots may be seen as preferable + * + * Goals: + * - Where possible, sessions that belong to the same track should take place + * in the same room. Because a session may belong to two tracks, this is not + * an absolute goal. + * - Schedule sessions back-to-back to avoid gaps. + * - Favor minimizing travels over using different rooms. + * - Session issue number should not influence slot and room (early proponents + * should not be favored or disfavored). + * - Minimize the number of rooms used in parallel. + * - Only one session labeled for a given track at the same time. + * - Only one session with a given chair at the same time. + * - No identified conflicting sessions at the same time. + * - Meet duration preference. + * - Meet capacity preference. + * + * The tool schedules as many sessions as possible, skipping over sessions that + * it cannot schedule due to a confict that it cannot resolve. + */ + +import { readFile } from 'fs/promises'; +import { getEnvKey } from './lib/envkeys.mjs'; +import { fetchProject, assignSessionsToSlotAndRoom } from './lib/project.mjs' +import { validateSession } from './lib/validate.mjs'; +import { validateGrid } from './lib/validate.mjs'; +import seedrandom from 'seedrandom'; + +const schedulingErrors = [ + 'error: chair conflict', + 'error: scheduling', + 'error: irc', + 'warning: capacity', + 'warning: conflict', + 'warning: duration', + 'warning: track' +]; + +/** + * Helper function to shuffle an array + */ +function shuffle(array, seed) { + const randomGenerator = seedrandom(seed); + for (let i = array.length - 1; i > 0; i--) { + let j = Math.floor(randomGenerator.quick() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } +} + +/** + * Helper function to generate a random seed + */ +function makeseed() { + const chars = 'abcdefghijklmnopqrstuvwxyz'; + return [1, 2, 3, 4, 5] + .map(_ => chars.charAt(Math.floor(Math.random() * chars.length))) + .join(''); +} + +async function main({ preserve, except, changesFile, apply, seed }) { + const PROJECT_OWNER = await getEnvKey('PROJECT_OWNER'); + const PROJECT_NUMBER = await getEnvKey('PROJECT_NUMBER'); + const CHAIR_W3CID = await getEnvKey('CHAIR_W3CID', {}, true); + console.warn(); + console.warn(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER}...`); + const project = await fetchProject(PROJECT_OWNER, PROJECT_NUMBER); + if (!project) { + throw new Error(`Project ${PROJECT_OWNER}/${PROJECT_NUMBER} could not be retrieved`); + } + project.chairsToW3CID = CHAIR_W3CID; + console.warn(`- found ${project.sessions.length} sessions`); + let sessions = await Promise.all(project.sessions.map(async session => { + const sessionErrors = (await validateSession(session.number, project)) + .filter(err => + err.severity === 'error' && + err.type !== 'chair conflict' && + err.type !== 'scheduling'); + if (sessionErrors.length > 0) { + return null; + } + return session; + })); + sessions = sessions.filter(s => !!s); + sessions.sort((s1, s2) => s1.number - s2.number); + console.warn(`- found ${sessions.length} valid sessions among them: ${sessions.map(s => s.number).join(', ')}`); + shuffle(sessions, seed); + console.warn(`- shuffled sessions with seed "${seed}" to: ${sessions.map(s => s.number).join(', ')}`); + console.warn(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER} and session(s)... done`); + + // Consider that default capacity is "average number of people" to avoid assigning + // sessions to too small rooms + for (const session of sessions) { + if (session.description.capacity === 0) { + session.description.capacity = 24; + } + } + + const rooms = project.rooms; + const slots = project.slots; + + seed = seed ?? makeseed(); + + // Load changes to apply locally if so requested + let changes = []; + if (changesFile) { + try { + changes = (await readFile(changesFile, 'utf8')) + .split('\n') + .map(line => line.trim()) + .filter(line => line.length && !line.startsWith(';')) + .map(line => { + const change = { + number: null, + slot: null, + room: null + }; + + // Line needs to start with session number + let match = line.match(/^(\d+)(.*)$/); + change.number = parseInt(match[1], 10); + + // Rest may either be a slot (possibly followed by a room) or a room + const rest = match[2].trim(); + match = rest.match(/^(?:(\d{1,2}:\d{1,2})|(\d+))(.*)$/); + if (match) { + // A slot was specified + change.slot = match[1] ? + slots.find(s => s.name.startsWith(match[1])).name : + slots[parseInt(match[2], 10)-1].name; + change.room = match[3].trim() ? + rooms.find(r => r.name.startsWith(match[3].trim())).name : + null; + } + else { + // No slot was specified, there should be a room + change.room = rest.trim() ? + rooms.find(r => r.name.startsWith(rest.trim())).name : + null; + } + return change; + }) + .filter(change => change.slot || change.room) + console.warn(changes); + } + catch (err) { + // Not a changes file! + throw err; + } + } + + // Save initial grid algorithm settings as CLI params + const cli = {}; + if (preserve === 'all') { + cli.preserve = 'all'; + } + else if (!preserve || preserve.length === 0) { + cli.preserve = 'none'; + } + else { + cli.preserve = preserve.join(','); + } + if (!except) { + cli.except = 'none'; + } + else if (except.length > 0) { + cli.except = except.join(','); + } + else { + cli.except = 'none'; + } + cli.seed = seed; + cli.apply = apply; + cli.cmd = `node tools/suggest-grid.mjs ${cli.preserve} ${cli.except} ${apply} ${cli.seed}`; + + if (preserve === 'all') { + preserve = sessions.filter(s => s.slot || s.room).map(s => s.number); + } + if (except) { + preserve = preserve.filter(s => !except.includes(s.number)); + } + if (!preserve) { + preserve = []; + } + for (const session of sessions) { + if (!preserve.includes(session.number)) { + session.slot = undefined; + session.room = undefined; + } + } + + // Initialize the list of tracks + const tracks = new Set(); + for (const session of sessions) { + session.tracks = session.labels + .filter(label => label.startsWith('track: ')) + .map(label => label.substring('track: '.length)) + .map(track => { + tracks.add(track); + return track; + }); + } + tracks.add(''); + + // Initalize the views by slot and by room + for (const slot of slots) { + slot.pos = slots.indexOf(slot); + slot.sessions = sessions.filter(s => s.slot === slot.name); + } + for (const room of rooms) { + room.pos = rooms.indexOf(room); + room.sessions = sessions.filter(s => s.room === room.name); + } + + // Return next session to process (and flag it as processed) + function selectNextSession(track) { + const session = sessions.find(s => !s.processed && + (track === '' || s.tracks.includes(track))); + if (session) { + session.processed = true; + } + return session; + } + + function chooseTrackRoom(track) { + if (!track) { + // No specific room by default for sessions in the main track + return null; + } + const trackSessions = sessions.filter(s => s.tracks.includes(track)); + + // Find the session in the track that requires the largest room + const largestSession = trackSessions.reduce( + (smax, scurr) => (scurr.description.capacity > smax.description.capacity) ? scurr : smax, + trackSessions[0] + ); + + const slotsTaken = room => room.sessions.reduce( + (total, curr) => curr.track === track ? total : total + 1, + 0); + const byAvailability = (r1, r2) => slotsTaken(r1) - slotsTaken(r2); + const meetCapacity = room => room.capacity >= largestSession.description.capacity; + const meetSameRoom = room => slotsTaken(room) + trackSessions.length <= slots.length; + const meetAll = room => meetCapacity(room) && meetSameRoom(room); + + const requestedRoomsSet = new Set(); + trackSessions + .filter(s => s.room) + .forEach(s => requestedRoomsSet.add(s.room)); + const requestedRooms = [...requestedRoomsSet] + .map(name => rooms.find(room => room.name === name)); + const allRooms = [] + .concat(requestedRooms.sort(byAvailability)) + .concat(rooms.filter(room => !requestedRooms.includes(room)).sort(byAvailability)) + const room = + allRooms.find(meetAll) ?? + allRooms.find(meetCapacity) ?? + allRooms.find(meetSameRoom) ?? + allRooms[0]; + return room; + } + + + function setRoomAndSlot(session, { + trackRoom, strictDuration, meetDuration, meetCapacity, meetConflicts + }) { + const byCapacity = (r1, r2) => r1.capacity - r2.capacity; + const byCapacityDesc = (r1, r2) => r2.capacity - r1.capacity; + + // List possible rooms: + // - If we explicitly set a room already, that's the only possibility. + // - Otherwise, if the default track room constraint is set, that's + // the only possible choice. + // - Otherwise, all rooms that have enough capacity are possible, + // or all rooms if capacity constraint has been relaxed already. + const possibleRooms = []; + if (session.room) { + // Keep room already assigned + possibleRooms.push(rooms.find(room => room.name === session.room)); + } + else if (trackRoom) { + // Need to assign the session to the track room + possibleRooms.push(trackRoom); + } + else { + // All rooms that have enough capacity are candidate rooms + possibleRooms.push(...rooms + .filter(room => room.capacity >= session.description.capacity) + .sort(byCapacity)); + if (!meetCapacity) { + possibleRooms.push(...rooms + .filter(room => room.capacity < session.description.capacity) + .sort(byCapacityDesc)); + } + } + + if (possibleRooms.length === 0) { + return false; + } + + for (const room of possibleRooms) { + // List possible slots in the current room: + // - If we explicitly set a slot already, that's the only possibility, + // provided the slot is available in that room! + // - Otherwise, all the slots that are still available in the room are + // possible. + // If we're dealing with a real track, we'll consider possible slots in + // order. If we're dealing with a session that is not in a track, + // possible slots are ordered so that less used ones get considered first + // (to avoid gaps). + const possibleSlots = []; + if (session.slot) { + const slot = slots.find(slot => slot.name === session.slot); + if (!room.sessions.find(s => s !== session && s.slot === session.slot)) { + possibleSlots.push(slot); + } + } + else { + possibleSlots.push(...slots + .filter(slot => !room.sessions.find(session => session.slot === slot.name))); + if (!trackRoom) { + // When not considering a specific track, fill slots in turn, + // starting with least busy ones + possibleSlots.sort((s1, s2) => { + const s1len = s1.sessions.length; + const s2len = s2.sessions.length; + if (s1len === s2len) { + return s1.pos - s2.pos; + } + else { + return s1len - s2len; + } + }); + } + } + + // A non-conflicting slot in the list of possible slots is one that does + // not lead to a situation where: + // - Two sessions in the same track are scheduled at the same time. + // - Two sessions chaired by the same person happen at the same time. + // - Conflicting sessions are scheduled at the same time. + // - Session is scheduled in a slot that does not meet the duration + // requirement. + // ... Unless these constraints have been relaxed! + function nonConflictingSlot(slot) { + const potentialConflicts = sessions.filter(s => + s !== session && s.slot === slot.name); + // There must be no session in the same track at that time + const trackConflict = potentialConflicts.find(s => + s.tracks.find(track => session.tracks.includes(track))); + if (trackConflict && meetConflicts.includes('track')) { + return false; + } + + // There must be no session chaired by the same chair at that time + const chairConflict = potentialConflicts.find(s => + s.chairs.find(c1 => session.chairs.find(c2 => + (c1.login && c1.login === c2.login) || + (c1.name && c1.name === c2.name))) + ); + if (chairConflict) { + return false; + } + + // There must be no conflicting sessions at the same time. + if (meetConflicts.includes('session')) { + const sessionConflict = potentialConflicts.find(s => + session.description.conflicts?.includes(s.number) || + s.description.conflicts?.includes(session.number)); + if (sessionConflict) { + return false; + } + } + + // Meet duration preference unless we don't care + if (meetDuration) { + if ((strictDuration && slot.duration !== session.description.duration) || + (!strictDuration && slot.duration < session.description.duration)) { + return false; + } + } + + return true; + } + + // Search for a suitable slot for the current room in the list. If one is + // found, we're done, otherwise move on to next possible room... or + // surrender for this set of constraints. + const slot = possibleSlots.find(nonConflictingSlot); + if (slot) { + if (!session.room) { + session.room = room.name; + session.updated = true; + room.sessions.push(session); + } + if (!session.slot) { + session.slot = slot.name; + session.updated = true; + slot.sessions.push(session); + } + return true; + } + } + + return false; + } + + // Proceed on a track-by-track basis, and look at sessions in each track in + // turn. + for (const track of tracks) { + // Choose a default track room that has enough capacity and enough + // available slots to fit all session tracks, if possible, starting with + // rooms that have a maximum number of available slots. Relax capacity and + // slot number constraints if there is no ideal candidate room. In + // practice, unless we're running short on rooms, this should select a room + // that is still unused for the track. + const trackRoom = chooseTrackRoom(track); + if (track) { + console.warn(`Schedule sessions in track "${track}" favoring room "${trackRoom.name}"...`); + } + else { + console.warn(`Schedule sessions in main track...`); + } + + // Process each session in the track in turn, unless it has already been + // processed (this may happen when the session belongs to two tracks). + let session = selectNextSession(track); + while (session) { + // Attempt to assign a room and slot that meets all constraints. + // If that fails, relax constraints one by one and start over. + // Scheduling may fail if there's no way to avoid a conflict and if + // that conflict cannot be relaxed (e.g., same person cannot chair two + // sessions at the same time). + const constraints = { + trackRoom, + strictDuration: true, + meetDuration: true, + meetCapacity: true, + meetConflicts: ['session', 'track'] + }; + while (!setRoomAndSlot(session, constraints)) { + if (constraints.strictDuration) { + console.warn(`- relax duration comparison for #${session.number}`); + constraints.strictDuration = false; + } + else if (constraints.trackRoom) { + console.warn(`- relax track constraint for #${session.number}`); + constraints.trackRoom = null; + } + else if (constraints.meetDuration) { + console.warn(`- forget duration constraint for #${session.number}`); + constraints.meetDuration = false; + } + else if (constraints.meetCapacity) { + console.warn(`- forget capacity constraint for #${session.number}`); + constraints.meetCapacity = false; + } + else if (constraints.meetConflicts.length === 2) { + console.warn(`- forget session conflicts for #${session.number}`); + constraints.meetConflicts = ['track']; + } + else if (constraints.meetConflicts[0] === 'track') { + console.warn(`- forget track conflicts for #${session.number}`); + constraints.meetConflicts = ['session']; + } + else if (constraints.meetConflicts.length > 0) { + console.warn(`- forget all conflicts for #${session.number}`); + constraints.meetConflicts = []; + } + else { + console.warn(`- could not find a room and slot for #${session.number}`); + break; + } + } + if (session.room && session.slot) { + console.warn(`- assigned #${session.number} to room ${session.room} and slot ${session.slot}`); + } + session = selectNextSession(track); + } + if (track) { + console.warn(`Schedule sessions in track "${track}" favoring room "${trackRoom.name}"... done`); + } + else { + console.warn(`Schedule sessions in main track... done`); + } + } + + sessions.sort((s1, s2) => s1.number - s2.number); + + for (const session of sessions) { + if (!session.slot || !session.room) { + const tracks = session.tracks.length ? ' - ' + session.tracks.join(', ') : ''; + console.warn(`- [WARNING] #${session.number} could not be scheduled${tracks}`); + } + } + + if (changes.length > 0) { + console.warn(); + console.warn(`Apply local changes...`); + for (const change of changes) { + const session = sessions.find(s => s.number === change.number); + if (change.room && change.room !== session.room) { + console.warn(`- move #${change.number} to room ${change.room}`); + session.room = change.room; + session.updated = true; + } + if (change.slot && change.slot !== session.slot) { + console.warn(`- move #${change.number} to slot ${change.slot}`); + session.slot = change.slot; + session.updated = true; + } + } + console.warn(`Apply local changes... done`); + } + + console.warn(); + console.warn(`Validate grid...`); + const errors = (await validateGrid(project)) + .filter(error => schedulingErrors.includes(`${error.severity}: ${error.type}`)); + if (errors.length) { + for (const error of errors) { + console.warn(`- [${error.severity}: ${error.type}] #${error.session}: ${error.messages.join(', ')}`); + } + } + else { + console.warn(`- looks good!`); + } + console.warn(`Validate grid... done`); + + function logIndent(tab, str) { + let spaces = ''; + while (tab > 0) { + spaces += ' '; + tab -= 1; + } + console.log(spaces + str); + } + + console.warn(); + logIndent(0, ` + + +`); + for (const room of rooms) { + logIndent(4, ' | ' + room.name + ' | '); + } + logIndent(3, '|
---|---|---|
');
+ logIndent(5, row[0]);
+
+ // Warn of any conflicting chairs in this slot (in first column)
+ let allchairnames = row.filter((s,i) => i > 0).filter((s) => typeof(s) === 'object').map((s) => s.chairs).flat(1).map(c => c.name);
+ let duplicates = allchairnames.filter((e, i, a) => a.indexOf(e) !== i);
+ if (duplicates.length) {
+ logIndent(5, ' Chair conflicts: ' + duplicates.join(', ') + ' '); + } + + // Warn if two sessions from the same track are scheduled in this slot + const alltracks = row.filter((s, i) => i > 0 && !!s).map(s => s.tracks).flat(1); + const trackdups = [...new Set(alltracks.filter((e, i, a) => a.indexOf(e) !== i))]; + if (trackdups.length) { + logIndent(5, 'Same track: ' + trackdups.join(', ') + ' '); + } + logIndent(4, ' | ');
+ // Format rest of row
+ for (let i = 1; i'); + } else { + logIndent(4, ' | ');
+ }
+ const url= 'https://github.com/' + session.repository + '/issues/' + session.number;
+ // Format session number (with link to GitHub) and name
+ logIndent(5, `#${session.number}: ${session.title}`);
+
+ // Format chairs
+ logIndent(5, ' ');
+ logIndent(6, '' + session.chairs.map(x => x.name).join(', ${track} `); + } + } + + // List session conflicts to avoid and highlight where there is a conflict. + if (Array.isArray(session.description.conflicts)) { + const confs = []; + for (const conflict of session.description.conflicts) { + for (const v of row) { + if (!!v && v.number === conflict) { + confs.push(conflict); + } + } + } + if (confs.length) { + logIndent(5, 'Conflicts with: ' + confs.map(s => '#' + s + '').join(', ') + ' '); + } + // This version prints all conflict info if we want that + // logIndent(5, 'Conflicts: ' + session.description.conflicts.map(s => confs.includes(s) ? '' + s + '' : s).join(', ') + ' '); + } + if (sloterrors.includes('capacity-error')) { + logIndent(5, 'Capacity: ' + session.description.capacity + ' '); + } + logIndent(4, ' | ');
+ }
+ }
+ logIndent(3, '
' + unscheduled.map(s => '#' + s.number).join(', ') + '
'); + } + + const preserveInPractice = (preserve !== 'all' && preserve.length > 0) ? + ' (in practice: ' + preserve.sort((n1, n2) => n1 - n2).join(',') + ')' : + ''; + logIndent(2, 'Command-line command:
+${cli.cmd}
`);
+ logIndent(2, ''); + console.log(JSON.stringify(sessions.map(s=> ({ number: s.number, room: s.room, slot: s.slot})), null, 2)); + logIndent(2, ''); + logIndent(1, ''); + logIndent(0, ''); + + console.warn(); + console.warn('To re-generate the grid, run:'); + console.warn(cli.cmd); + + if (apply) { + console.warn(); + const sessionsToUpdate = sessions.filter(s => s.updated); + for (const session of sessionsToUpdate) { + console.warn(`- updating #${session.number}...`); + await assignSessionsToSlotAndRoom(session, project); + console.warn(`- updating #${session.number}... done`); + } + } +} + + +// Read preserve list from command-line +let preserve; +if (process.argv[2]) { + if (!process.argv[2].match(/^all|none|\d+(,\d+)*$/)) { + console.warn('Command needs to receive a list of issue numbers as first parameter or "all"'); + process.exit(1); + } + if (process.argv[2] === 'all') { + preserve = 'all'; + } + else if (process.argv[2] === 'none') { + preserve = []; + } + else { + preserve = process.argv[2].map(n => parseInt(n, 10)); + } +} + +// Read except list +let except; +if (process.argv[3]) { + if (!process.argv[3].match(/^none|\d+(,\d+)*$/)) { + console.warn('Command needs to receive a list of issue numbers as second parameter or "none"'); + process.exit(1); + } + except = process.argv[3] === 'none' ? + undefined : + process.argv[3].split(',').map(n => parseInt(n, 10)); +} + +const apply = process.argv[4] === 'apply'; +const changesFile = apply ? undefined : (process.argv[4] ?? undefined); +const seed = process.argv[5] ?? undefined; + +main({ preserve, except, changesFile, apply, seed }) + .catch(err => { + console.warn(`Something went wrong: ${err.message}`); + throw err; + }); diff --git a/tools/update-calendar.mjs b/tools/update-calendar.mjs new file mode 100644 index 0000000..6b48f57 --- /dev/null +++ b/tools/update-calendar.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +import puppeteer from 'puppeteer'; +import { getEnvKey } from './lib/envkeys.mjs'; +import { fetchProject } from './lib/project.mjs'; +import { convertSessionToCalendarEntry } from './lib/calendar.mjs'; +import { validateSession } from './lib/validate.mjs'; + +async function main(sessionNumber, status) { + console.log(`Retrieve environment variables...`); + const PROJECT_OWNER = await getEnvKey('PROJECT_OWNER'); + console.log(`- PROJECT_OWNER: ${PROJECT_OWNER}`); + const PROJECT_NUMBER = await getEnvKey('PROJECT_NUMBER'); + console.log(`- PROJECT_NUMBER: ${PROJECT_NUMBER}`); + const CALENDAR_SERVER = await getEnvKey('CALENDAR_SERVER', 'www.w3.org'); + console.log(`- CALENDAR_SERVER: ${CALENDAR_SERVER}`); + const W3C_LOGIN = await getEnvKey('W3C_LOGIN'); + console.log(`- W3C_LOGIN: ${W3C_LOGIN}`); + const W3C_PASSWORD = await getEnvKey('W3C_PASSWORD'); + console.log(`- W3C_PASSWORD: ***`); + const ROOM_ZOOM = await getEnvKey('ROOM_ZOOM', {}, true); + const CHAIR_W3CID = await getEnvKey('CHAIR_W3CID', {}, true); + console.log(`Retrieve environment variables... done`); + + console.log(); + console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER} and session(s)...`); + const project = await fetchProject(PROJECT_OWNER, PROJECT_NUMBER); + if (!project) { + throw new Error(`Project ${PROJECT_OWNER}/${PROJECT_NUMBER} could not be retrieved`); + } + project.chairsToW3CID = CHAIR_W3CID; + let sessions = sessionNumber ? + project.sessions.filter(s => s.number === sessionNumber) : + project.sessions.filter(s => s.slot); + sessions.sort((s1, s2) => s1.number - s2.number); + if (sessionNumber) { + if (sessions.length === 0) { + throw new Error(`Session ${sessionNumber} not found in project ${PROJECT_OWNER}/${PROJECT_NUMBER}`); + } + else if (!sessions[0].slot) { + throw new Error(`Session ${sessionNumber} not assigned to a slot in project ${PROJECT_OWNER}/${PROJECT_NUMBER}`); + } + } + else { + console.log(`- found ${sessions.length} sessions assigned to slots: ${sessions.map(s => s.number).join(', ')}`); + } + sessions = await Promise.all(sessions.map(async session => { + const sessionErrors = (await validateSession(session.number, project)) + .filter(error => error.severity === 'error'); + if (sessionErrors.length > 0) { + return null; + } + return session; + })); + sessions = sessions.filter(s => !!s); + if (sessionNumber) { + if (sessions.length === 0) { + throw new Error(`Session ${sessionNumber} contains errors that need fixing`); + } + } + else { + console.log(`- found ${sessions.length} valid sessions among them: ${sessions.map(s => s.number).join(', ')}`); + } + console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER} and session(s)... done`); + + console.log(); + console.log('Launch Puppeteer...'); + const browser = await puppeteer.launch({ headless: true }); + console.log('Launch Puppeteer... done'); + + try { + for (const session of sessions) { + console.log(); + console.log(`Convert session ${session.number} to calendar entry...`); + const room = project.rooms.find(r => r.name === session.room); + const zoom = ROOM_ZOOM[room?.label] ? ROOM_ZOOM[room.label] : undefined; + await convertSessionToCalendarEntry({ + browser, session, project, status, zoom, + calendarServer: CALENDAR_SERVER, + login: W3C_LOGIN, + password: W3C_PASSWORD + }); + console.log(`Convert session ${session.number} to calendar entry... done`); + } + } + finally { + await browser.close(); + } +} + + +// Read session number from command-line +const allSessions = process.argv[2]; +if (!allSessions || !allSessions.match(/^\d+$|^all$/)) { + console.log('Command needs to receive a session number, or "all", as first parameter'); + process.exit(1); +} +const sessionNumber = allSessions === 'all' ? undefined : parseInt(allSessions, 10); + +const status = process.argv[3] ?? 'draft'; +if (!['draft', 'tentative', 'confirmed'].includes(status)) { + console.log('Command needs to receive a valid entry status "draft", "tentative" or "confirmed" as second parameter'); + process.exit(1); +} + +main(sessionNumber, status) + .catch(err => { + console.log(`Something went wrong: ${err.message}`); + throw err; + }); \ No newline at end of file diff --git a/tools/upload-grid.mjs b/tools/upload-grid.mjs new file mode 100644 index 0000000..18bb7a2 --- /dev/null +++ b/tools/upload-grid.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +import fs from 'fs'; +import { getEnvKey } from './lib/envkeys.mjs'; +import { fetchProject, assignSessionsToSlotAndRoom } from './lib/project.mjs' + +function readconfig(filename) { + if (filename) { + let content = fs.readFileSync(filename).toString(); + // Don't want room names with in them! + let regexp = /
(.*)<\/pre>/s; + let data = content.match(regexp)[1]; + return(JSON.parse(data)); + } +} + +async function main({ filename, apply }) { + const PROJECT_OWNER = await getEnvKey('PROJECT_OWNER'); + const PROJECT_NUMBER = await getEnvKey('PROJECT_NUMBER'); + console.warn(); + console.warn(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER}...`); + const project = await fetchProject(PROJECT_OWNER, PROJECT_NUMBER); + if (!project) { + throw new Error(`Project ${PROJECT_OWNER}/${PROJECT_NUMBER} could not be retrieved`); + } + console.warn(`- found ${project.sessions.length} sessions`); + let sessions = await Promise.all(project.sessions); + sessions = sessions.filter(s => !!s); + console.warn(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER} and session(s)... done`); + + console.warn(`Extract grid from HTML page...`); + const rooms = project.rooms; + const slots = project.slots; + const configs = readconfig(filename); + console.warn(`Extract grid from HTML page... done`); + + console.warn(`Assign sessions to rooms and slots...`); + const updated = []; + for (const config of configs) { + if (!sessions.map(s => s.number === config.number)) { + throw new Error('Unknown session ' + config.number); + } + if (!slots.map(s => s.name === config.slot)) { + throw new Error('Unknown slot ' + config.slot + ' in ' + config.number); + } + if (!rooms.map(s => s.name === config.room)) { + throw new Error('Unknown room ' + config.room + ' in ' + config.number); + } + let session = sessions.find(s => s.number === config.number); + if (session.room !== config.room || session.slot !== config.slot) { + session.room = config.room; + session.slot = config.slot; + updated.push(session); + } + } + + if (apply) { + for (const session of updated) { + console.warn(`- updating #${session.number}...`); + await assignSessionsToSlotAndRoom(session, project); + console.warn(`- updating #${session.number}... done`); + } + console.warn(updated.length ? + `- ${updated.length} sessions updated` : + '- no session to update'); + } + else { + console.warn(updated.length ? + `- ${updated.length} sessions would be updated: ${updated.map(s => s.number).join(', ')}` : + '- no session would be updated'); + } + console.warn(`Assign sessions to rooms and slots... done`); +} + + +// filename is an HTML file generated by suggest-grid.mjs that +// contains the raw session data +let filename +if (!process.argv[2]) { + console.warn('Missing first param: HTML file with grid and raw data'); + } else { + filename = process.argv[2]; + } + +let apply; +if (process.argv[3]) { + apply = process.argv[3]; +} + +main({ filename, apply }) + .catch(err => { + console.warn(`Something went wrong: ${err.message}`); + throw err; + }); diff --git a/tools/validate-grid.mjs b/tools/validate-grid.mjs new file mode 100644 index 0000000..31ad493 --- /dev/null +++ b/tools/validate-grid.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node +/** + * This tool validates the grid and sets a few validation results accordingly. + * Unless user requests full re-validation of the sessions, validation results + * managed by the tool are those related to scheduling problems (in other words, + * problems that may arise when an admin chooses a room and slot). + * + * To run the tool: + * + * node tools/validate-grid.mjs [validation] + * + * where [validation] is either "scheduling" (default) to validate only + * scheduling conflicts or "everything" to re-validate all sessions. + */ + +import { getEnvKey } from './lib/envkeys.mjs'; +import { fetchProject, saveSessionValidationResult } from './lib/project.mjs' +import { validateGrid } from './lib/validate.mjs'; +import { sendGraphQLRequest } from './lib/graphql.mjs'; + +const schedulingErrors = [ + 'error: chair conflict', + 'error: scheduling', + 'error: irc', + 'warning: capacity', + 'warning: conflict', + 'warning: duration', + 'warning: track' +]; + +async function main(validation) { + // First, retrieve known information about the project and the session + const PROJECT_OWNER = await getEnvKey('PROJECT_OWNER'); + const PROJECT_NUMBER = await getEnvKey('PROJECT_NUMBER'); + const CHAIR_W3CID = await getEnvKey('CHAIR_W3CID', {}, true); + console.log(); + console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER}...`); + const project = await fetchProject(PROJECT_OWNER, PROJECT_NUMBER); + if (!project) { + throw new Error(`Project ${PROJECT_OWNER}/${PROJECT_NUMBER} could not be retrieved`); + } + project.chairsToW3CID = CHAIR_W3CID; + console.log(`- ${project.sessions.length} sessions`); + console.log(`- ${project.rooms.length} rooms`); + console.log(`- ${project.slots.length} slots`); + console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER}... done`); + + console.log(); + console.log(`Validate grid...`); + const errors = (await validateGrid(project)) + .filter(error => validation === 'everything' || schedulingErrors.includes(`${error.severity}: ${error.type}`)); + console.log(`- ${errors.length} problems found`); + console.log(`Validate grid... done`); + + // Time to record session validation issues + const sessions = [... new Set(errors.map(error => error.session))] + .map(number => project.sessions.find(s => s.number === number)); + for (const session of sessions) { + console.log(); + console.log(`Save validation results for session ${session.number}...`); + for (const severity of ['Error', 'Warning', 'Check']) { + let results = errors + .filter(error => error.session === session.number && error.severity === severity.toLowerCase()) + .map(error => error.type); + if (severity === 'Check' && + session.validation.check?.includes('irc channel') && + !results.includes('irc channel')) { + // Need to keep the 'irc channel' value until an admin removes it + results.push('irc channel'); + } + else if (severity === 'Warning' && session.validation.note) { + results = results.filter(warning => { + const keep = + !session.validation.note.includes(`-warning:${warning}`) && + !session.validation.note.includes(`-warn:${warning}`) && + !session.validation.note.includes(`-w:${warning}`); + if (!keep) { + console.log(`- drop warning:${warning} per note`); + } + return keep; + }); + } + if (validation !== 'everything' && session.validation[severity.toLowerCase()]) { + // Need to preserve previous results that touched on other aspects + const previousResults = session.validation[severity.toLowerCase()] + .split(',') + .map(value => value.trim()); + for (const result of previousResults) { + if (!schedulingErrors.includes(`${severity.toLowerCase()}: ${result}`)) { + results.push(result); + } + } + } + results = results.sort(); + session.validation[severity.toLowerCase()] = results.join(', '); + } + await saveSessionValidationResult(session, project); + console.log(`Save validation results for session ${session.number}... done`); + } + + if (validation !== 'everything') { + const resetSessions = project.sessions.filter(session => + !sessions.find(s => s.number === session.number)); + for (const session of resetSessions) { + let updated = false; + for (const severity of ['Error', 'Warning', 'Check']) { + if (!session.validation[severity.toLowerCase()]) { + continue; + } + let results = []; + const previousResults = session.validation[severity.toLowerCase()] + .split(',') + .map(value => value.trim()); + for (const result of previousResults) { + if (!schedulingErrors.includes(`${severity.toLowerCase()}: ${result}`)) { + results.push(result); + } + } + if (results.length !== previousResults.length) { + results = results.sort(); + session.validation[severity.toLowerCase()] = results.join(', '); + updated = true; + } + } + if (updated) { + console.log(`Save validation results for session ${session.number}...`); + await saveSessionValidationResult(session, project); + console.log(`Save validation results for session ${session.number}... done`); + } + } + } +} + + +main(process.argv[2] ?? 'scheduling') + .catch(err => { + console.log(`Something went wrong: ${err.message}`); + throw err; + }); \ No newline at end of file diff --git a/tools/validate-session.mjs b/tools/validate-session.mjs new file mode 100644 index 0000000..6a19f7f --- /dev/null +++ b/tools/validate-session.mjs @@ -0,0 +1,197 @@ +#!/usr/bin/env node +/** + * This tool validates a session issue and manages validation results in the + * project accordingly. + * + * To run the tool: + * + * node tools/validate-session.mjs [sessionNumber] [changes] + * + * where [sessionNumber] is the number of the issue to validate (e.g. 15) + * and [changes] is the filename of a JSON file that describes changes made to + * the body of the issue (e.g. changes.json). + * + * The JSON file should look like: + * { + * "body": { + * "from": "[previous version]" + * } + * } + * + * The JSON file typically matches github.event.issue.changes in a GitHub job. + */ + +import { getEnvKey } from './lib/envkeys.mjs'; +import { fetchProject, saveSessionValidationResult } from './lib/project.mjs' +import { validateSession } from './lib/validate.mjs'; +import { parseSessionBody, updateSessionDescription } from './lib/session.mjs'; +import { sendGraphQLRequest } from './lib/graphql.mjs'; + +/** + * Helper function to generate a shortname from the session's title + */ +function generateShortname(session) { + return '#' + session.title + .toLowerCase() + .replace(/\([^\)]\)/g, '') + .replace(/[^a-z0-0\-\s]/g, '') + .replace(/\s+/g, '-'); +} + +async function main(sessionNumber, changesFile) { + // First, retrieve known information about the project and the session + const PROJECT_OWNER = await getEnvKey('PROJECT_OWNER'); + const PROJECT_NUMBER = await getEnvKey('PROJECT_NUMBER'); + const CHAIR_W3CID = await getEnvKey('CHAIR_W3CID', {}, true); + console.log(); + console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER}...`); + const project = await fetchProject(PROJECT_OWNER, PROJECT_NUMBER); + const session = project.sessions.find(s => s.number === sessionNumber); + if (!project) { + throw new Error(`Project ${PROJECT_OWNER}/${PROJECT_NUMBER} could not be retrieved`); + } + if (!session) { + throw new Error(`Session ${sessionNumber} not found in project ${PROJECT_OWNER}/${PROJECT_NUMBER}`); + } + console.log(`- ${project.sessions.length} sessions`); + console.log(`- ${project.rooms.length} rooms`); + console.log(`- ${project.slots.length} slots`); + project.chairsToW3CID = CHAIR_W3CID; + console.log(`Retrieve project ${PROJECT_OWNER}/${PROJECT_NUMBER}... done`); + + console.log(); + console.log(`Validate session...`); + let report = await validateSession(sessionNumber, project, changes); + for (const error of report) { + console.log(`- ${error.severity}:${error.type}: ${error.messages.join(', ')}`); + } + console.log(`Validate session... done`); + + const checkComments = report.find(error => + error.severity === 'check' && error.type === 'instructions'); + if (checkComments && + !session.validation.check?.includes('instructions') && + changesFile) { + // The session contains comments and does not have a "check: instructions" + // flag. That said, an admin may already have validated these comments + // (and removed the flag). We should only add it back if the comments + // section changed. + console.log(); + console.log(`Assess need to add "check: instructions" flag...`); + + // Read JSON file that describes changes if one was given + // (needs to contain a dump of `github.event.changes` when run in a job) + const { default: changes } = await import( + ['..', changesFile].join('/'), + { assert: { type: 'json' } } + ); + if (!changes.body?.from) { + console.log(`- no previous version of session body, add flag`); + } + else { + console.log(`- previous version of session body found`); + try { + const previousDescription = parseSessionBody(changes.body.from); + const newDescription = parseSessionBody(session.body); + if (newDescription.comments === previousDescription.comments) { + console.log(`- no change in comments section, no need to add flag`); + report = report.filter(error => + !(error.severity === 'check' && error.type === 'instructions')); + } + else { + console.log(`- comments section changed, add flag`); + } + } + catch { + // Previous version could not be parsed. Well, too bad, let's add + // the "check: comments" flag then. + // TODO: consider doing something smarter as broken format errors + // will typically arise when author adds links to agenda/minutes. + console.log(`- previous version of session body could not be parsed, add flag`); + } + } + console.log(`Assess need to add "check: instructions" flag... done`); + } + + // No IRC channel provided, one will be created, let's add a + // "check: irc channel" flag + if (!report.find(err => err.severity === 'error' && err.type === 'format') && + !session.description.shortname) { + report.push({ + session: sessionNumber, + severity: 'check', + type: 'irc channel', + messages: ['IRC channel was generated from the title'] + }); + } + + // Time to record session validation issues + console.log(); + console.log(`Save session validation results...`); + for (const severity of ['Error', 'Warning', 'Check']) { + let results = report + .filter(error => error.severity === severity.toLowerCase()) + .map(error => error.type) + .sort(); + if (severity === 'Check' && + session.validation.check?.includes('irc channel') && + !results.includes('irc channel')) { + // Need to keep the 'irc channel' value until an admin removes it + results.push('irc channel'); + results = results.sort(); + } + else if (severity === 'Warning' && session.validation.note) { + results = results.filter(warning => { + const keep = + !session.validation.note.includes(`-warning:${warning}`) && + !session.validation.note.includes(`-warn:${warning}`) && + !session.validation.note.includes(`-w:${warning}`); + if (!keep) { + console.log(`- drop warning:${warning} per note`); + } + return keep; + }); + } + session.validation[severity.toLowerCase()] = results.join(', '); + } + await saveSessionValidationResult(session, project); + console.log(`Save session validation results... done`); + + // Prefix IRC channel with '#' if not already done + if (!report.find(err => err.severity === 'error' && err.type === 'format') && + session.description.shortname && + !session.description.shortname.startsWith('#')) { + console.log(); + console.log(`Add '#' prefix to IRC channel...`); + session.description.shortname = '#' + session.description.shortname; + await updateSessionDescription(session); + console.log(`Add '#' prefix to IRC channel... done`); + } + + // Or generate IRC channel if it was not provided. + if (!report.find(err => err.severity === 'error' && err.type === 'format') && + !session.description.shortname) { + console.log(); + console.log(`Generate IRC channel...`); + session.description.shortname = generateShortname(session); + await updateSessionDescription(session); + console.log(`Generate IRC channel... done`); + } +} + + +// Read session number from command-line +if (!process.argv[2] || !process.argv[2].match(/^\d+$/)) { + console.log('Command needs to receive a session number as first parameter'); + process.exit(1); +} +const sessionNumber = parseInt(process.argv[2], 10); + +// Read change filename from command-line if specified +const changes = process.argv[3]; + +main(sessionNumber, changes) + .catch(err => { + console.log(`Something went wrong: ${err.message}`); + throw err; + }); \ No newline at end of file