From 254240126ed2c7e0edc66c399febcddd772b44f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Daoust?= Date: Fri, 13 Sep 2024 11:02:50 +0200 Subject: [PATCH] Add new "view-registrants" CLI command, replaces "assess-room-capacity" (#168) The new command replaces the "assess-room-capacity" command and lets one visualize the number of participants and observers of sessions, and generate a report with sessions in large enough rooms, in rooms that are too small due to the number of participants, and in rooms that are too small due to the number of observers. The numbers of registrants are retrieved from a `Registrants` custom field in the project. Command options make it possible to: 1. Fetch updated numbers from a registrants page (for TPAC events). 2. Update these numbers in the project's `Registrants` custom field. 3. Restrict the output to warnings (rooms too small) --- test/stubs.mjs | 12 ++ tools/cli.mjs | 25 ++- tools/commands/assess-rooms-capacity.mjs | 187 ----------------- tools/commands/view-registrants.mjs | 252 +++++++++++++++++++++++ tools/lib/project.mjs | 45 +++- 5 files changed, 316 insertions(+), 205 deletions(-) delete mode 100644 tools/commands/assess-rooms-capacity.mjs create mode 100644 tools/commands/view-registrants.mjs diff --git a/test/stubs.mjs b/test/stubs.mjs index f643186..7cdd874 100644 --- a/test/stubs.mjs +++ b/test/stubs.mjs @@ -100,6 +100,12 @@ async function getTestData(testDataId) { field: { name: 'Try me out' } }); } + if (session.registrants) { + fields.push({ + text: session.registrants, + field: { name: 'Registrants' } + }); + } return { id: `id_${uid++}`, @@ -148,6 +154,9 @@ async function getTestData(testDataId) { if (custom.allowTryMeOut) { testData.allowTryMeOut = custom.allowTryMeOut; } + if (custom.allowRegistrants) { + testData.allowRegistrants = custom.allowRegistrants; + } testDataCache[testDataId] = testData; return JSON.parse(JSON.stringify(testData)); @@ -182,6 +191,9 @@ export async function sendGraphQLRequest(query, acceptHeader = '') { else if ((name === 'Try me out') && !testData.allowTryMeOut) { field = null; } + else if ((name === 'Registrants') && !testData.allowRegistrants) { + field = null; + } else { field = { id: `id_field_${name}`, diff --git a/tools/cli.mjs b/tools/cli.mjs index b576bce..8fbd4b3 100644 --- a/tools/cli.mjs +++ b/tools/cli.mjs @@ -17,8 +17,8 @@ import schedule from './commands/schedule.mjs'; import synchronizeCalendar from './commands/sync-calendar.mjs'; import validate from './commands/validate.mjs'; import viewEvent from './commands/view-event.mjs'; +import viewRegisrants from './commands/view-registrants.mjs'; import tryChanges from './commands/try-changes.mjs'; -import assessRoomsCapacity from './commands/assess-rooms-capacity.mjs'; /** @@ -238,19 +238,24 @@ Examples: /****************************************************************************** - * The "assess-rooms" command + * The "view-registrants" command *****************************************************************************/ program - .command('assess-rooms') - .summary('Assess assigned rooms capacity against the actual number of registrants for a TPAC event.') - .description('Assess assigned rooms capacity against the actual number of registrants for each meeting for a TPAC event.') - .argument('', 'meeting rooms to assess. Either a group session number or "all" to assess all meeting rooms.') - .option('-u, --url ', 'URL of the page that lists the registrants per meeting. The code uses `https://www.w3.org/register/[meeting name]/registrants` when not given') - .action(getCommandRunner(assessRoomsCapacity)) + .command('view-registrants') + .summary('View the number of participants and observers for each session.') + .description('View the number of participants and observers for each session, possibly fetching the information from a registrants page (for TPAC events).') + .argument('', 'session to view. Either a session number or "all" to view information for all sessions.') + .option('-f, --fetch', 'fetch the registrants information from the registrants page.') + .option('-s, --save', 'save registrants information to the project. The --fetch option must be set.') + .option('-u, --url ', 'URL of the page that lists the registrants per session. The code uses `https://www.w3.org/register/[meeting name]/registrants` when not given. The --fetch option must be set.') + .option('-w, --warnings-only', 'Only return information about sessions that meet in rooms that are too small.') + .action(getCommandRunner(viewRegisrants)) .addHelpText('after', ` Examples: - $ npx tpac-breakouts assess-rooms all - $ npx tpac-breakouts assess-rooms 42 + $ npx tpac-breakouts view-registrants all + $ npx tpac-breakouts view-registrants all -w + $ npx tpac-breakouts view-registrants all --fetch --save + $ npx tpac-breakouts view-registrants all --fetch --url https://example.org/registrants `); diff --git a/tools/commands/assess-rooms-capacity.mjs b/tools/commands/assess-rooms-capacity.mjs deleted file mode 100644 index fde7f20..0000000 --- a/tools/commands/assess-rooms-capacity.mjs +++ /dev/null @@ -1,187 +0,0 @@ -import puppeteer from 'puppeteer'; -import { validateSession } from '../lib/validate.mjs'; -import { getEnvKey } from '../lib/envkeys.mjs'; -import { parseSessionMeetings } from '../lib/meetings.mjs'; - -/** - * Login to W3C server. - * - * The function throws if login fails. - * - * TODO: same code as in tools/lib/calendar.mjs, move to common lib - */ -export async function authenticate(page, login, password, redirectUrl) { - const url = await page.evaluate(() => window.location.href); - if (!url.endsWith('/login')) { - return; - } - - const usernameInput = await page.waitForSelector('input#username'); - await usernameInput.type(login); - - const passwordInput = await page.waitForSelector('input#password'); - await passwordInput.type(password); - - const submitButton = await page.waitForSelector('button[type=submit]'); - await submitButton.click(); - - await page.waitForNavigation(); - const newUrl = await page.evaluate(() => window.location.href); - if (newUrl !== redirectUrl) { - throw new Error('Could not login. Invalid credentials?'); - } -} - -export default async function (project, number, options) { - const meeting = project.metadata.meeting.toLowerCase().replace(/\s+/g, ''); - const registrantsUrl = options?.url ?? - `https://www.w3.org/register/${meeting}/registrants`; - - console.warn(`Retrieve environment variables...`); - const W3C_LOGIN = await getEnvKey('W3C_LOGIN'); - console.warn(`- W3C_LOGIN: ${W3C_LOGIN}`); - const W3C_PASSWORD = await getEnvKey('W3C_PASSWORD'); - console.warn(`- W3C_PASSWORD: ***`); - console.warn(`Retrieve environment variables... done`); - - let attendance = []; - - console.warn(); - console.warn('Launch Puppeteer...'); - const browser = await puppeteer.launch({ headless: true }); - console.warn('Launch Puppeteer... done'); - - try { - const page = await browser.newPage(); - try { - await page.goto(registrantsUrl); - await authenticate(page, W3C_LOGIN, W3C_PASSWORD, registrantsUrl); - attendance = await page.evaluate(() => - [...document.querySelectorAll('h2[id^=meeting]')] - .map(heading => { - const res = { - id: heading.id, - // Note ad-hoc fixes for a few typos in TPAC 2024 registrants - // page: https://www.w3.org/register/tpac2024/registrants - groups: heading - .innerText - .replace(/JSON for Linking Data/, 'JSON for Linked Data') - .replace(/Accessible Platform Architectures joint/, 'Accessible Platform Architectures WG joint') - .replace(/(meeting )+on-site attendance/i, '') - .replace(/Attendance for the/i, '') - .split(' joint meeting with ') - .map((item, idx) => idx === 0 ? item : item.split(',')) - .flat() - .map(g => g.trim()), - nbParticipants: 0, - nbObservers: 0 - }; - - let el = heading.nextElementSibling; - if (el?.nodeName !== 'P') { - // Should be a paragraph with the number of participants. - return res; - } - const nbParticipants = el.innerText - .match(/(\d+) people registered as group participant/); - if (nbParticipants) { - res.nbParticipants = parseInt(nbParticipants[1], 10); - el = el.nextElementSibling; - if (el?.nodeName !== 'UL') { - // Should be the list of participants. - return res; - } - el = el.nextElementSibling; - if (el?.nodeName !== 'P') { - // Should be a paragraph that with an "Email all at once" link. - return res; - } - el = el.nextElementSibling; - if (el?.nodeName !== 'P') { - // Should be a paragraph with the number of observers. - return res; - } - const nbObservers = el.innerText - .match(/(\d+) people registered as observer/); - if (nbObservers) { - res.nbObservers = parseInt(nbObservers[1], 10); - } - } - return res; - }) - ); - } - finally { - await page.close(); - } - } - finally { - console.warn(); - console.warn('Close Puppeteer...'); - await browser.close(); - console.warn('Close Puppeteer... done'); - } - - console.warn(); - console.warn('Validate sessions...'); - const sessions = project.sessions.filter(session => - number.toLowerCase() === 'all' || session.number === parseInt(number, 10)); - for (const session of sessions) { - await validateSession(session.number, project); - } - console.warn('Validate sessions... done'); - - console.warn(); - console.warn('Map registration page to sessions...'); - const mapped = attendance.map(meetingAttendance => { - const groups = meetingAttendance.groups; - const session = sessions.find(session => - (groups.length === 1 && groups[0] === session.title) || - (groups.length === session.groups.length && - groups.every(group => session.groups.find(g => g.name === group)))); - if (!session) { - if (number.toLowerCase() === 'all') { - console.warn(`- warning: coud not find a session for "${groups.join(', ')}"`); - } - return null; - } - const meetings = parseSessionMeetings(session, project); - const rooms = meetings - .map(meeting => project.rooms.find(room => room.name === meeting.room)) - .filter(room => !!room) - .filter((room, idx, list) => list.findIndex(r => r.name === room.name) === idx); - return Object.assign({}, session, { - id: meetingAttendance.id, - nbParticipants: meetingAttendance.nbParticipants, - nbObservers: meetingAttendance.nbObservers, - rooms - }); - }).filter(s => !!s); - console.warn('Map registration page to sessions... done'); - - console.warn(); - console.warn('Assess meeting rooms capacity...'); - const sessionUrl = `https://github.com/${session.repository}/issues/${session.number}`; - console.log('## Too many Participants'); - for (const session of mapped) { - const tooSmall = session.rooms.filter(room => room.capacity < session.nbParticipants); - if (tooSmall.length > 0) { - console.log(`- [${session.title}](${sessionUrl})`); - for (const room of tooSmall) { - console.log(` - in ${room.label}: capacity is ${room.capacity}, [${session.nbParticipants} group participants](${registrantsUrl}#${session.id}) (plus ${session.nbObservers} observers, total: ${session.nbParticipants + session.nbObservers}).`); - } - } - } - console.log('\n## Too many Observers'); - for (const session of mapped) { - const tooSmall = session.rooms.filter(room => - room.capacity > session.nbParticipants && room.capacity < session.nbParticipants + session.nbObservers); - if (tooSmall.length > 0) { - console.log(`- [${session.title}](${sessionUrl})`); - for (const room of tooSmall) { - console.log(` - in ${room.label}: capacity is ${room.capacity}, [${session.nbParticipants} group participants](${registrantsUrl}#${session.id}) but also ${session.nbObservers} observers, total: ${session.nbParticipants + session.nbObservers}.`) - } - } - } - console.warn('Assess meeting rooms capacity... done'); -} diff --git a/tools/commands/view-registrants.mjs b/tools/commands/view-registrants.mjs new file mode 100644 index 0000000..f629ec3 --- /dev/null +++ b/tools/commands/view-registrants.mjs @@ -0,0 +1,252 @@ +import puppeteer from 'puppeteer'; +import { validateSession } from '../lib/validate.mjs'; +import { authenticate } from '../lib/calendar.mjs'; +import { getEnvKey } from '../lib/envkeys.mjs'; +import { parseSessionMeetings } from '../lib/meetings.mjs'; +import { saveSessionMeetings } from '../lib/project.mjs'; + +export default async function (project, number, options) { + const meeting = project.metadata.meeting.toLowerCase().replace(/\s+/g, ''); + const registrantsUrl = options?.url ?? + `https://www.w3.org/register/${meeting}/registrants`; + + console.warn('Validate options...'); + if (options?.save && !options?.fetch) { + console.error('- The --fetch option must be set when --save is set'); + return; + } + if (options?.url && !options?.fetch) { + console.error('- The --fetch option must be set when --url is set'); + return; + } + console.warn('Validate options... done'); + + console.warn('Validate sessions...'); + const sessions = project.sessions.filter(session => + number.toLowerCase() === 'all' || session.number === parseInt(number, 10)); + for (const session of sessions) { + await validateSession(session.number, project); + } + console.warn('Validate sessions... done'); + + if (options?.fetch) { + console.warn('Launch Puppeteer...'); + const browser = await puppeteer.launch({ headless: true }); + console.warn('Launch Puppeteer... done'); + + let allRegistrants = []; + try { + console.warn(`Retrieve environment variables...`); + const W3C_LOGIN = await getEnvKey('W3C_LOGIN'); + console.warn(`- W3C_LOGIN: ${W3C_LOGIN}`); + const W3C_PASSWORD = await getEnvKey('W3C_PASSWORD'); + console.warn(`- W3C_PASSWORD: ***`); + console.warn(`Retrieve environment variables... done`); + + console.warn('Fetch registrants...'); + const page = await browser.newPage(); + try { + await page.goto(registrantsUrl); + await authenticate(page, W3C_LOGIN, W3C_PASSWORD, registrantsUrl); + allRegistrants = await page.evaluate(() => + [...document.querySelectorAll('h2[id^=meeting]')] + .map(heading => { + const res = { + id: heading.id, + // Note ad-hoc fixes for a few typos in TPAC 2024 registrants + // page: https://www.w3.org/register/tpac2024/registrants + groups: heading + .innerText + .replace(/JSON for Linking Data/, 'JSON for Linked Data') + .replace(/Accessible Platform Architectures joint/, 'Accessible Platform Architectures WG joint') + .replace(/(meeting )+on-site attendance/i, '') + .replace(/Attendance for the/i, '') + .split(' joint meeting with ') + .map((item, idx) => idx === 0 ? item : item.split(',')) + .flat() + .map(g => g.trim()), + participants: 0, + observers: 0 + }; + + let el = heading.nextElementSibling; + if (el?.nodeName !== 'P') { + // Should be a paragraph with the number of participants. + return res; + } + const nbParticipants = el.innerText + .match(/(\d+) people registered as group participant/); + if (nbParticipants) { + res.participants = parseInt(nbParticipants[1], 10); + el = el.nextElementSibling; + if (el?.nodeName !== 'UL') { + // Should be the list of participants. + return res; + } + el = el.nextElementSibling; + if (el?.nodeName !== 'P') { + // Should be a paragraph that with an "Email all at once" link. + return res; + } + el = el.nextElementSibling; + if (el?.nodeName !== 'P') { + // Should be a paragraph with the number of observers. + return res; + } + const nbObservers = el.innerText + .match(/(\d+) people registered as observer/); + if (nbObservers) { + res.observers = parseInt(nbObservers[1], 10); + } + } + return res; + }) + ); + } + finally { + console.warn('Fetch registrants... done'); + await page.close(); + } + } + finally { + console.warn('Close Puppeteer...'); + await browser.close(); + console.warn('Close Puppeteer... done'); + } + + console.warn('Refresh registrants information in sessions...'); + for (const session of sessions) { + const sessionRegistrants = allRegistrants.find(entry => { + const groups = entry.groups; + return (groups.length === 1 && groups[0] === session.title) || + (groups.length === session.groups.length && + groups.every(group => session.groups.find(g => g.name === group))); + }); + if (sessionRegistrants) { + const registrants = '' + sessionRegistrants.participants + + '+' + sessionRegistrants.observers; + if (session.registrants !== registrants) { + console.warn(`- update ${session.title}: "${registrants}" (was "${session.registrants ?? ''}")`); + session.registrants = registrants; + session.registrantsUrl = `${registrantsUrl}#${sessionRegistrants.id}`; + session.updated = true; + } + } + else { + console.warn(`- warning: coud not find registrants for "${session.title}"`); + } + } + console.warn('Refresh registrants information in sessions... done'); + } + + console.warn('Expand registrants info...'); + const expanded = sessions.map(session => { + const match = (session.registrants ?? '') + .match(/^\s*(\d+)\s*(?:\+\s*(\d+)\s*)?$/); + const participants = parseInt(match?.[1] ?? '0', 10); + const observers = parseInt(match?.[2] ?? '0', 10); + const sessionUrl = + `https://github.com/${session.repository}/issues/${session.number}`; + const registrantsUrl = session.registrantsUrl ?? sessionUrl; + const markdown = session.registrantsUrl ? + `[${session.title}](${sessionUrl}) - [${participants} participants plus ${observers} observers](${registrantsUrl}), total: ${participants + observers}` : + `[${session.title}](${sessionUrl}) - ${participants} participants plus ${observers} observers, total: ${participants + observers}`; + return { + session, + participants, + observers, + total: participants + observers, + markdown + }; + }) + console.warn('Expand registrants info... done'); + + console.warn('Retrieve session rooms...'); + for (const entry of expanded) { + const meetings = parseSessionMeetings(entry.session, project); + entry.rooms = meetings + .map(meeting => project.rooms.find(room => room.name === meeting.room)) + .filter(room => !!room) + .filter((room, idx, list) => list.findIndex(r => r.name === room.name) === idx) + .map(room => Object.assign({}, room, { + diffParticipants: entry.participants - room.capacity, + diffTotal: entry.total - room.capacity + })); + } + console.warn('Retrieve session rooms... done'); + + if (options?.save) { + console.warn('Record registrants in project...'); + if (project.allowRegistrants) { + const sessionsToUpdate = project.sessions.filter(s => s.updated); + for (const session of sessionsToUpdate) { + console.warn(`- updating #${session.number}...`); + await saveSessionMeetings(session, project, { fields: ['registrants'] }); + console.warn(`- updating #${session.number}... done`); + } + } + else { + console.warn('- no "Registrants" custom field found in project'); + } + console.warn('Record registrants in project... done'); + } + + console.warn('Assess meeting rooms capacity...'); + const report = { + greatBigRooms: [], + middleSizedRooms: [], + littleWeeRooms: [] + }; + for (const entry of expanded) { + const greatBigRooms = entry.rooms.filter(room => + room.capacity >= entry.total); + if (greatBigRooms.length > 0) { + report.greatBigRooms.push( + Object.assign({}, entry, { rooms: greatBigRooms })); + } + const middleSizedRooms = entry.rooms.filter(room => + room.capacity < entry.total && + room.capacity >= entry.participants); + if (middleSizedRooms.length > 0) { + report.middleSizedRooms.push( + Object.assign({}, entry, { rooms: middleSizedRooms })); + } + const littleWeeRooms = entry.rooms.filter(room => + room.capacity < entry.participants); + if (littleWeeRooms.length > 0) { + report.littleWeeRooms.push( + Object.assign({}, entry, { rooms: littleWeeRooms })); + } + } + if (!options?.warningsOnly && report.greatBigRooms.length > 0) { + console.log(); + console.log('## Meetings in large enough rooms'); + for (const entry of report.greatBigRooms) { + console.log(`- ${entry.markdown}`); + for (const room of entry.rooms) { + console.log(` - in ${room.label}: capacity is ${room.capacity}, ${0 - room.diffTotal} seat${0 - room.diffTotal <= 1 ? '' : 's'} available.`); + } + } + } + if (report.littleWeeRooms.length > 0) { + console.log(); + console.log('## Too many Participants'); + for (const entry of report.littleWeeRooms) { + console.log(`- ${entry.markdown}`); + for (const room of entry.rooms) { + console.log(` - in ${room.label}: capacity is ${room.capacity}, ${room.diffParticipants} seat${room.diffParticipants <= 1 ? '' : 's'} missing.`); + } + } + } + if (report.middleSizedRooms.length > 0) { + console.log(); + console.log('## Too many Observers'); + for (const entry of report.middleSizedRooms) { + console.log(`- ${entry.markdown}`); + for (const room of entry.rooms) { + console.log(` - in ${room.label}: capacity is ${room.capacity}, ${room.diffTotal} seat${room.diffTotal <= 1 ? '' : 's'} missing.`); + } + } + } + console.warn('Assess meeting rooms capacity... done'); +} diff --git a/tools/lib/project.mjs b/tools/lib/project.mjs index 46c34a5..7904b67 100644 --- a/tools/lib/project.mjs +++ b/tools/lib/project.mjs @@ -637,6 +637,21 @@ export async function fetchProject(login, id) { }`); const tryMeeting = tryMeetingResponse.data[type].projectV2.field; + // And a "Registrants" custom field to record registrants to the session + const registrantsResponse = await sendGraphQLRequest(`query { + ${type}(login: "${login}"){ + projectV2(number: ${id}) { + field(name: "Registrants") { + ... on ProjectV2FieldCommon { + id + name + } + } + } + } + }`); + const registrants = registrantsResponse.data[type].projectV2.field; + // Another request to retrieve the list of sessions associated with the project. const sessionsResponse = await sendGraphQLRequest(`query { ${type}(login: "${login}") { @@ -845,6 +860,13 @@ export async function fetchProject(login, id) { trymeoutsFieldId: tryMeeting?.id, allowTryMeOut: !!tryMeeting?.id, + // ID of the "Registrants" custom field, if it exists + // (it signals the ability to look at registrants to select rooms) + // (note: the double "s" is needed because our convention was to make that + // a plural of the custom field name, which happens to be a plural already) + registrantssFieldId: registrants?.id, + allowRegistrants: !!registrants?.id, + // Sections defined in the issue template sessionSections, @@ -877,6 +899,8 @@ export async function fetchProject(login, id) { .find(value => value.field?.name === 'Meeting')?.text, trymeout: session.fieldValues.nodes .find(value => value.field?.name === 'Try me out')?.text, + registrants: session.fieldValues.nodes + .find(value => value.field?.name === 'Registrants')?.text, validation: { check: session.fieldValues.nodes.find(value => value.field?.name === 'Check')?.text, warning: session.fieldValues.nodes.find(value => value.field?.name === 'Warning')?.text, @@ -914,13 +938,15 @@ function parseProjectDescription(desc) { /** * Record the meetings assignments for the provided session */ -export async function saveSessionMeetings(session, project) { - for (const field of ['room', 'day', 'slot', 'meeting', 'trymeout']) { - // Project may not allow multiple meetings - if (!project[field + 'sFieldId']) { - continue; - } - const prop = (field === 'meeting' || field === 'trymeout') ? 'text': 'singleSelectOptionId'; +export async function saveSessionMeetings(session, project, options) { + // Project may not have some of the custom fields, and we may only + // be interested in a restricted set of them + const fields = ['room', 'day', 'slot', 'meeting', 'trymeout', 'registrants'] + .filter(field => project[field + 'sFieldId']) + .filter(field => !options || !options.fields || options.fields.includes(field)); + for (const field of fields) { + const prop = ['meeting', 'trymeout', 'registrants'].includes(field) ? + 'text': 'singleSelectOptionId'; let value = null; if (prop === 'text') { // Text field @@ -1050,6 +1076,9 @@ export function convertProjectToJSON(project) { if (project.allowTryMeOut) { data.allowTryMeOut = true; } + if (project.allowRegistrants) { + data.allowRegistrants = true; + } for (const list of ['days', 'rooms', 'slots', 'labels']) { data[list] = toNameList(project[list]); } @@ -1064,7 +1093,7 @@ export function convertProjectToJSON(project) { if (session.labels.length !== 1 || session.labels[0] !== 'session') { simplified.labels = session.labels; } - for (const field of ['day', 'room', 'slot', 'meeting']) { + for (const field of ['day', 'room', 'slot', 'meeting', 'registrants']) { if (session[field]) { simplified[field] = session[field]; }