diff --git a/package.json b/package.json index babe22c..a248f22 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "test": "mocha", "appscript-pull": "clasp pull", "appscript-push": "clasp push", - "appscript-bundle": "rollup tools/appscript/main.mjs --file tools/appscript/bundle.js --no-treeshake", + "appscript-bundle": "rollup tools/appscript/main.mjs --file tools/appscript/bundle.js --no-treeshake --external ../../test/stubs.mjs", "appscript": "npm run appscript-pull && npm run appscript-bundle && npm run appscript-push" }, "engines": { diff --git a/test/stubs.mjs b/test/stubs.mjs index 9acc0cc..81440ba 100644 --- a/test/stubs.mjs +++ b/test/stubs.mjs @@ -268,6 +268,21 @@ export async function sendGraphQLRequest(query, acceptHeader = '') { } }; } + else if (query.includes('projectsV2(')) { + const type = query.includes('organization(') ? 'organization' : 'user' + const id = query.match(/repository\(name: "([^"]+)"\)/)[1]; + const result = { data: {} }; + result.data[type] = { + repository: { + projectsV2: { + nodes: [{ + number: id + }] + } + } + }; + return result; + } else { throw new Error('Unexpected GraphQL query request, cannot fake it!', { cause: query }); } diff --git a/tools/appscript/add-custom-menu.mjs b/tools/appscript/add-custom-menu.mjs index 56789aa..f8b1682 100644 --- a/tools/appscript/add-custom-menu.mjs +++ b/tools/appscript/add-custom-menu.mjs @@ -5,9 +5,16 @@ */ export default function () { SpreadsheetApp.getUi().createMenu('TPAC') - .addItem('Export event data as JSON', 'exportEventData') - .addItem('Import data from GitHub', 'importFromGithub') - .addItem('Generate grid', 'generateGrid') + .addItem('Refresh grid', 'generateGrid') + .addItem('Validate grid', 'validateGrid') + .addSeparator() + .addSubMenu( + SpreadsheetApp.getUi() + .createMenu('Sync with GitHub') + .addItem('Import data from GitHub', 'importFromGitHub') + .addItem('Export data to GitHub', 'exportToGitHub') + .addItem('Export event data as JSON', 'exportEventData') + ) .addToUi(); } diff --git a/tools/appscript/export-to-github.mjs b/tools/appscript/export-to-github.mjs new file mode 100644 index 0000000..7b0db32 --- /dev/null +++ b/tools/appscript/export-to-github.mjs @@ -0,0 +1,25 @@ +import { getProject } from './project.mjs'; +import reportError from './report-error.mjs'; + +/** + * Trigger a GitHub workflow that refreshes the data from GitHub + */ +export default function () { + const project = getProject(SpreadsheetApp.getActiveSpreadsheet()); + + if (!project.metadata.reponame) { + reportError(`No GitHub repository associated with the current document. + +Make sure that the "GitHub repository name" parameter is set in the "Event" sheet. + +Also make sure the targeted repository and project have been properly initialized. +If not, ask François or Ian to run the required initialization steps.`); + } + + const repoparts = project.metadata.reponame.split('/'); + const repo = { + owner: repoparts.length > 1 ? repoparts[0] : 'w3c', + name: repoparts.length > 1 ? repoparts[1] : repoparts[0] + }; + +} \ No newline at end of file diff --git a/tools/appscript/import-from-github.mjs b/tools/appscript/import-from-github.mjs index 7b63f3b..92dc89d 100644 --- a/tools/appscript/import-from-github.mjs +++ b/tools/appscript/import-from-github.mjs @@ -1,10 +1,13 @@ import { getProject } from './project.mjs'; import reportError from './report-error.mjs'; +import { fetchProjectFromGitHub } from '../common/project.mjs'; +import { refreshProject } from './project.mjs'; +import * as YAML from '../../node_modules/yaml/browser/index.js'; /** * Trigger a GitHub workflow that refreshes the data from GitHub */ -export default function () { +export default async function () { const project = getProject(SpreadsheetApp.getActiveSpreadsheet()); if (!project.metadata.reponame) { @@ -14,6 +17,7 @@ Make sure that the "GitHub repository name" parameter is set in the "Event" shee Also make sure the targeted repository and project have been properly initialized. If not, ask François or Ian to run the required initialization steps.`); + return; } const repoparts = project.metadata.reponame.split('/'); @@ -22,38 +26,40 @@ If not, ask François or Ian to run the required initialization steps.`); name: repoparts.length > 1 ? repoparts[1] : repoparts[0] }; - const options = { - method : 'post', - contentType: 'application/json', - payload : JSON.stringify({ - ref: 'main', - inputs: { - sheet: spreadsheet.getId() - } - }), - headers: { - 'Authorization': `Bearer ${GITHUB_TOKEN}` - }, - muteHttpExceptions: true - }; + let githubProject; + try { + const yamlTemplateResponse = UrlFetchApp.fetch( + `https://raw.githubusercontent.com/${repo.owner}/${repo.name}/refs/heads/main/.github/ISSUE_TEMPLATE/session.yml` + ); + const yamlTemplate = yamlTemplateResponse.getContentText(); + const template = YAML.parse(yamlTemplate); - const response = UrlFetchApp.fetch( - `https://api.github.com/repos/${repo.owner}/${repo.name}/actions/workflows/sync-spreadsheet.yml/dispatches`, - options); - const status = response.getResponseCode(); - if (status === 200 || status === 204) { - const ui = SpreadsheetApp.getUi(); - ui.alert( - 'Patience...', - `A job was scheduled to refresh the data in the spreadsheet. This may take a while... - -The job will clear the grid in the process. Please run "Generate grid" again once the grid is empty.`, - ui.ButtonSet.OK + githubProject = await fetchProjectFromGitHub( + repo.owner === 'w3c' ? repo.owner : `user/${repo.owner}`, + repo.name, + template ); } - else { - reportError(`Unexpected HTTP status ${status} received from GitHub. + catch (err) { + reportError(err.toString()); + return; + } -Data could not be imported from ${repo}.`); + try { + refreshProject(SpreadsheetApp.getActiveSpreadsheet(), githubProject, { + what: 'all' + }); } + catch(err) { + reportError(err.toString()); + return; + } + + const htmlOutput = HtmlService + .createHtmlOutput( + '
' + JSON.stringify(githubProject, null, 2) + '
' + ) + .setWidth(300) + .setHeight(400); + SpreadsheetApp.getUi().showModalDialog(htmlOutput, 'GitHub project'); } \ No newline at end of file diff --git a/tools/appscript/main.mjs b/tools/appscript/main.mjs index 3a07175..6e5472e 100644 --- a/tools/appscript/main.mjs +++ b/tools/appscript/main.mjs @@ -1,11 +1,15 @@ import _createOnOpenTrigger from './create-onopen-trigger.mjs'; import _addTPACMenu from './add-custom-menu.mjs'; import _importFromGitHub from './import-from-github.mjs'; +import _exportToGitHub from './export-to-github.mjs'; import _generateGrid from './generate-grid.mjs'; +import _validateGrid from './validate-grid.mjs'; import _exportEventData from './export-event-data.mjs'; function main() { _createOnOpenTrigger(); } function addTPACMenu() { _addTPACMenu(); } function importFromGitHub() { _importFromGitHub(); } +function exportToGitHub() { _exportToGitHub(); } function generateGrid() { _generateGrid(); } +function validateGrid() { _validateGrid(); } function exportEventData() { _exportEventData(); } diff --git a/tools/appscript/project.mjs b/tools/appscript/project.mjs index 4129c42..a4bef32 100644 --- a/tools/appscript/project.mjs +++ b/tools/appscript/project.mjs @@ -1,5 +1,5 @@ import { getEnvKey } from '../common/envkeys.mjs'; -import * as YAML from '../../node_modules/yaml/browser/index.js'; +import { getSessionSections } from '../common/session-sections.mjs'; /** * Retrieve an indexed object that contains the list of sheets associated with @@ -10,7 +10,7 @@ export function getProjectSheets(spreadsheet) { const sheets = { grid: {}, event: { titleMatch: /event/i }, - sessions: { titleMatch: /list/i }, + sessions: { titleMatch: /(list|breakouts)/i }, meetings: { titleMatch: /meetings/i }, rooms: { titleMatch: /rooms/i }, days: { titleMatch: /days/i }, @@ -129,31 +129,13 @@ export function getProject(spreadsheet) { } // Parse YAML GitHub issue template if it exists + let sessionTemplate = null; let sessionSections = []; - const yamlTemplate = getSetting('GitHub issue template'); - if (yamlTemplate) { - const template = YAML.parse(yamlTemplate); - sessionSections = template.body.filter(section => !!section.id); - - // The "calendar" and "materials" sections are not part of the template. - // They are added manually or automatically when need arises. For the - // purpose of validation and serialization, we need to add them to the list - // of sections (as custom "auto hide" sections that only get displayed when - // they are not empty). - sessionSections.push({ - id: 'calendar', - attributes: { - label: 'Links to calendar', - autoHide: true - } - }); - sessionSections.push({ - id: 'materials', - attributes: { - label: 'Meeting materials', - autoHide: true - } - }); + const sessionTemplateKey = spreadsheet.getDeveloperMetadata() + .find(data => data.getKey() === 'session-template'); + if (sessionTemplateKey) { + sessionTemplate = JSON.parse(sessionTemplateKey.getValue()); + sessionSections = getSessionSections(sessionTemplate); } const eventType = getSetting('Type', 'TPAC breakouts'); @@ -224,6 +206,7 @@ export function getProject(spreadsheet) { allowTryMeOut: false, allowRegistrants: false, + sessionTemplate, sessionSections, // TODO: how to retrieve the labels? @@ -268,4 +251,302 @@ function getValues(sheet) { return value; }); return values; +} + + +/** + * Update the values in a sheet from a list of objects whose property names are + * derived from the header row. + */ +function setValues(sheet, values) { + const nbRows = sheet.getLastRow() - 1; + const nbColumns = sheet.getLastColumn(); + console.log('rows cols', nbRows, nbColumns); + + const headers = sheet + .getRange(1, 1, 1, nbColumns) + .getValues()[0] + .map(value => value.toLowerCase()) + .map(value => { + // Some headers get mapped to a shorter name + if (value === 'start time') { + return 'start'; + } + if (value === 'end time') { + return 'end'; + } + if (value === 'vip room') { + return 'vip'; + } + return value; + }); + console.log('headers', headers); + + // Values is an array of indexed objects, while we need a two-dimensional + // array of raw values. Let's convert the values. + const rawValues = values.map((obj, vidx) => headers.map(header => { + if (!obj.hasOwnProperty(header)) { + return ''; + } + if (obj[header] === true) { + return 'yes'; + } + if (obj[header] === false) { + return 'no'; + } + if (obj[header] === null) { + return ''; + } + if (header === 'author' && obj[header].login) { + return obj[header].login; + } + return obj[header]; + })); + console.log('raw values', rawValues); + + // Note: we may have more or less rows than in the current sheet + if (nbRows > 0) { + const updateNb = Math.min(nbRows, rawValues.length); + const updateRange = sheet.getRange( + 2, 1, updateNb, nbColumns + ); + updateRange.setValues(rawValues.slice(0, updateNb)); + if (nbRows > rawValues.length) { + const clearRange = sheet.getRange( + rawValues.length + 2, 1, + nbRows - rawValues.length, nbColumns + ); + clearRange.clear(); + } + } + if (nbRows < rawValues.length) { + const appendRange = sheet.getRange( + nbRows + 2, 1, + rawValues.length - nbRows, nbColumns); + appendRange.setValues(rawValues.slice(nbRows)); + } +} + +/** + * Refresh the project's data in the spreadsheet with information from GitHub + */ +export function refreshProject(spreadsheet, project, { what }) { + const sheets = getProjectSheets(spreadsheet); + + function setSetting(name, value) { + const metadataRows = sheets.event.sheet.getDataRange().getValues(); + const rowIdx = metadataRows.findIndex(row => row[0] === name); + let cell; + if (rowIdx === -1) { + const lastRow = metadataRows.getLastRow(); + cell = sheets.event.sheet.getRange(lastRow + 1, 1); + cell.setValue(name); + cell = sheets.event.sheet.getRange(lastRow + 1, 2); + cell.setValue(value); + } + else { + cell = sheets.event.sheet.getRange(rowIdx + 1, 2); + cell.setValue(value); + } + } + + function refreshData(type) { + // The column that contains the identifier on which to match values + // depends on the type of data being updated. + let idKey = 'id'; + if (type === 'rooms') { + idKey = 'name'; + } + else if (type === 'days') { + idKey = 'date'; + } + else if (type === 'slots') { + idKey = 'start'; + } + else if (type === 'sessions') { + idKey = 'number'; + } + console.log(type, idKey); + const values = sheets[type].values ?? []; + const seen = []; + for (const obj of project[type]) { + const value = values.find(val => val[idKey] === obj[idKey]); + if (value) { + // Existing item, refresh the data + for (const [key, val] of Object.entries(obj)) { + value[key] = val; + } + seen.push(value); + } + else { + // New item, add to the end of the list + values.push(obj); + seen.push(obj); + } + } + const toset = values.filter(value => seen.includes(value)); + console.log(type, toset.length); + setValues(sheets[type].sheet, toset); + + // Set formula to auto-fill the weekday in the days sheet + // and the slot name in the slots sheet + // TODO: this assumes a position for the columns + if (type === 'days') { + const range = sheets[type].sheet.getRange('B2:B'); + range.setFormulaR1C1( + '=IF(INDIRECT("R[0]C[-1]", false) <> "", CHOOSE(WEEKDAY(INDIRECT("R[0]C[-1]", false)), "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" ), "")' + ); + } + if (type === 'slots') { + const range = sheets[type].sheet.getRange('C2:C'); + range.setFormulaR1C1( + '=IF(INDIRECT("R[0]C[-2]", false) <> "", CONCAT(CONCAT(INDIRECT("R[0]C[-2]", false), "-"), INDIRECT("R[0]C[-1]", false)), "")' + ); + } + + // To make team's life easier, we'll convert session numbers in the first + // column to a link to the session on GitHub + if (type === 'sessions' && toset.length > 0) { + const richValues = toset + .map(session => SpreadsheetApp + .newRichTextValue() + .setText(session.number) + .setLinkUrl(`https://github.com/${session.repository}/issues/${session.number}`) + .build() + ) + .map(richValue => [richValue]); + const range = sheets[type].sheet.getRange(2, 1, toset.length, 1); + range.setRichTextValues(richValues); + } + + SpreadsheetApp.flush(); + } + + // Refresh metadata settings + if (what === 'all' || what === 'metadata') { + // Refresh the session template + const sessionTemplate = spreadsheet.getDeveloperMetadata() + .find(data => data.getKey() === 'session-template'); + const value = JSON.stringify(project.sessionTemplate, null, 2) + if (sessionTemplate) { + sessionTemplate.setValue(value); + } + else { + spreadsheet.addDeveloperMetadata('session-template', value); + } + + // TODO: Refresh the list of labels + + for (const [name, value] of Object.entries(project.metadata)) { + if (name === 'type') { + // TODO: Refresh event type? There's one more type in the spreadsheet + } + else if (name === 'rooms') { + setSetting('Show rooms in calendar', !!value ? 'yes' : 'no'); + } + else if (name === 'calendar') { + setSetting('Sync with W3C calendar', value); + } + else if (name === 'timezone') { + setSetting('Timezone', value); + } + else if (name === 'meeting') { + setSetting('Meeting name in calendar', value); + } + else { + setSetting(name, value); + } + } + SpreadsheetApp.flush(); + + // Refresh rooms, days, slots + for (const type of ['rooms', 'days', 'slots']) { + refreshData(type); + } + } + + // Refresh sessions + if (what === 'all' || what === 'sessions') { + if (!sheets.sessions.sheet) { + sheets.sessions.sheet = createSessionsSheet(spreadsheet, sheets, project); + } + refreshData('sessions'); + } + + // TODO: refresh meetings (only for TPAC events) +} + +function createSessionsSheet(spreadsheet, sheets, project) { + // Create the new sheet + const title = project.metadata.type === 'groups' ? 'List' : 'Breakouts'; + const sheet = spreadsheet.insertSheet(title, spreadsheet.getSheets().length - 2); + + // Set the headers row + const headers = [ + 'Number', 'Title', 'Author', 'Body', 'Labels', + 'Room', 'Day', 'Slot', 'Meeting', + 'Error', 'Warning', 'Check', 'Note', + 'Registrants' + ]; + const headersRow = sheet.getRange(1, 1, 1, headers.length); + headersRow.setValues([headers]); + headersRow.setFontWeight('bold'); + sheet.setFrozenRows(1); + headersRow + .protect() + .setDescription(`${title} - headers`) + .setWarningOnly(true); + + sheet.setRowHeightsForced(2, sheet.getMaxRows() - 1, 60); + sheet.setColumnWidths(headers.findIndex(h => h === 'Number') + 1, 1, 60); + sheet.setColumnWidths(headers.findIndex(h => h === 'Title') + 1, 1, 300); + sheet.setColumnWidths(headers.findIndex(h => h === 'Body') + 1, 1, 300); + sheet.setColumnWidths(headers.findIndex(h => h === 'Room') + 1, 1, 150); + sheet.setColumnWidths(headers.findIndex(h => h === 'Day') + 1, 1, 150); + + // TODO: this assumes that room name is in column "A". + const roomValuesRange = sheets.rooms.sheet.getRange('A2:A'); + const roomRule = SpreadsheetApp + .newDataValidation() + .requireValueInRange(roomValuesRange) + .setAllowInvalid(false) + .build(); + const roomRange = sheet.getRange( + 2, headers.findIndex(h => h === 'Room') + 1, + sheet.getMaxRows() - 1, 1); + roomRange.setDataValidation(roomRule); + + // TODO: this assumes that day name is in column "A". + const dayValuesRange = sheets.days.sheet.getRange('A2:A'); + const dayRule = SpreadsheetApp + .newDataValidation() + .requireValueInRange(dayValuesRange) + .setAllowInvalid(false) + .build(); + const dayRange = sheet.getRange( + 2, headers.findIndex(h => h === 'Day') + 1, + sheet.getMaxRows() - 1, 1); + dayRange.setDataValidation(dayRule); + + // TODO: this assumes that slot name is in column "C". + const slotValuesRange = sheets.slots.sheet.getRange('C2:C'); + const slotRule = SpreadsheetApp + .newDataValidation() + .requireValueInRange(slotValuesRange) + .setAllowInvalid(false) + .build(); + const slotRange = sheet.getRange( + 2, headers.findIndex(h => h === 'Slot') + 1, + sheet.getMaxRows() - 1, 1); + slotRange.setDataValidation(slotRule); + + sheet + .getRange(2, 1, + sheet.getMaxRows() - 1, + headers.findIndex(h => h === 'Labels') + 1) + .protect() + .setDescription(`${title} - content from GitHub`) + .setWarningOnly(true); + + return sheet; } \ No newline at end of file diff --git a/tools/appscript/validate-grid.mjs b/tools/appscript/validate-grid.mjs new file mode 100644 index 0000000..4ab6c39 --- /dev/null +++ b/tools/appscript/validate-grid.mjs @@ -0,0 +1,19 @@ +import { getProject } from './project.mjs'; +import { validateGrid } from '../common/validate.mjs'; + +/** + * Export the event data as JSON + */ +export default async function () { + const project = getProject(SpreadsheetApp.getActiveSpreadsheet()); + + const res = await validateGrid(project, { what: 'everything' }); + + const htmlOutput = HtmlService + .createHtmlOutput( + '
' + JSON.stringify(res, null, 2) + '
' + ) + .setWidth(300) + .setHeight(400); + SpreadsheetApp.getUi().showModalDialog(htmlOutput, 'Validation result'); +} \ No newline at end of file diff --git a/tools/common/project.mjs b/tools/common/project.mjs index 9b0d73d..4872e6e 100644 --- a/tools/common/project.mjs +++ b/tools/common/project.mjs @@ -1,4 +1,456 @@ import timezones from './timezones.mjs'; +import { sendGraphQLRequest } from './graphql.mjs'; +import { getSessionSections } from './session-sections.mjs'; + +/** + * Retrieve available project data from GitHub. + * + * This includes: + * - the list of rooms and their capacity + * - the list of days + * - the list of slots and their duration + * - the detailed list of breakout sessions associated with the project + * - the room and slot that may already have been associated with each session + * + * Returned object should look like: + * { + * "title": "TPAC xxxx breakout sessions", + * "url": "https://github.com/orgs/w3c/projects/xx", + * "id": "xxxxxxx", + * "roomsFieldId": "xxxxxxx", + * "rooms": [ + * { "id": "xxxxxxx", "name": "Salon Ecija (30)", "label": "Salon Ecija", "capacity": 30 }, + * ... + * ], + * "slotsFieldId": "xxxxxxx", + * "slots": [ + * { "id": "xxxxxxx", "name": "9:30 - 10:30", "start": "9:30", "end": "10:30", "duration": 60 }, + * ... + * ], + * "severityFieldIds": { + * "Check": "xxxxxxx", + * "Warning": "xxxxxxx", + * "Error": "xxxxxxx", + * "Note": "xxxxxxx" + * }, + * "sessions": [ + * { + * "repository": "w3c/tpacxxxx-breakouts", + * "number": xx, + * "title": "Session title", + * "body": "Session body, markdown", + * "labels": [ "session", ... ], + * "author": { + * "databaseId": 1122927, + * "login": "tidoust" + * }, + * "room": "Salon Ecija (30)", + * "slot": "9:30 - 10:30" + * }, + * ... + * ], + * "labels": [ + * { + * "id": "xxxxxxx", + * "name": "error: format" + * }, + * ... + * ] + * } + */ +export async function fetchProjectFromGitHub(login, id, sessionTemplate) { + // Login is an organization name... or starts with "user/" to designate + // a user project. + const tokens = login.split('/'); + const type = (tokens.length === 2) && tokens[0] === 'user' ? + 'user' : + 'organization'; + login = (tokens.length === 2) ? tokens[1] : login; + + // The ID is not the project number but the repository name, let's retrieve + // the project number from the repository + if (!('' + id).match(/^\d+$/)) { + const projResponse = await sendGraphQLRequest(`query { + ${type}(login: "${login}") { + repository(name: "${id}") { + projectsV2(first: 10) { + nodes { + number + } + } + } + } + }`); + id = projResponse.data[type].repository.projectsV2.nodes[0].number; + } + + // Retrieve information about the list of rooms + const roomsResponse = await sendGraphQLRequest(`query { + ${type}(login: "${login}"){ + projectV2(number: ${id}) { + id + url + title + shortDescription + field(name: "Room") { + ... on ProjectV2SingleSelectField { + id + name + options { + ... on ProjectV2SingleSelectFieldOption { + id + name + description + } + } + } + } + } + } + }`); + const project = roomsResponse.data[type].projectV2; + const rooms = project.field; + + // Similar request to list time slots + const slotsResponse = await sendGraphQLRequest(`query { + ${type}(login: "${login}"){ + projectV2(number: ${id}) { + field(name: "Slot") { + ... on ProjectV2SingleSelectField { + id + name + options { + ... on ProjectV2SingleSelectFieldOption { + id + name + } + } + } + } + } + } + }`); + const slots = slotsResponse.data[type].projectV2.field; + + // Similar request to list event days + const daysResponse = await sendGraphQLRequest(`query { + ${type}(login: "${login}"){ + projectV2(number: ${id}) { + field(name: "Day") { + ... on ProjectV2SingleSelectField { + id + name + options { + ... on ProjectV2SingleSelectFieldOption { + id + name + } + } + } + } + } + } + }`); + const days = daysResponse.data[type].projectV2.field; + + // Similar requests to get the ids of the custom fields used for validation + const severityFieldIds = {}; + for (const severity of ['Error', 'Warning', 'Check', 'Note']) { + const response = await sendGraphQLRequest(`query { + ${type}(login: "${login}"){ + projectV2(number: ${id}) { + field(name: "${severity}") { + ... on ProjectV2FieldCommon { + id + name + } + } + } + } + }`); + severityFieldIds[severity] = response.data[type].projectV2.field.id; + } + + // Project may also have a "Meeting" custom field when a session can be + // scheduled multiple times. The field contains the list of (room, day, slot) + // tuples that a session is associated with. + const meetingResponse = await sendGraphQLRequest(`query { + ${type}(login: "${login}"){ + projectV2(number: ${id}) { + field(name: "Meeting") { + ... on ProjectV2FieldCommon { + id + name + } + } + } + } + }`); + const meeting = meetingResponse.data[type].projectV2.field; + + // Project may also have a "Try me out" custom field to adjust the schedule + const tryMeetingResponse = await sendGraphQLRequest(`query { + ${type}(login: "${login}"){ + projectV2(number: ${id}) { + field(name: "Try me out") { + ... on ProjectV2FieldCommon { + id + name + } + } + } + } + }`); + 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}") { + projectV2(number: ${id}) { + items(first: 100) { + nodes { + id + content { + ... on Issue { + id + repository { + owner { + login + } + name + nameWithOwner + } + number + state + title + body + labels(first: 20) { + nodes { + name + } + } + author { + ... on User { + databaseId + } + login + } + } + } + fieldValues(first: 10) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + name + field { + ... on ProjectV2SingleSelectField { + name + } + } + } + ... on ProjectV2ItemFieldTextValue { + text + field { + ... on ProjectV2FieldCommon { + name + } + } + } + } + } + } + } + } + } + }`); + const sessions = sessionsResponse.data[type].projectV2.items.nodes; + + let labels = []; + if (sessions.length > 0) { + const repository = sessions[0].content.repository; + const labelsResponse = await sendGraphQLRequest(`query { + repository(owner: "${repository.owner.login}", name: "${repository.name}") { + labels(first: 50) { + nodes { + id + name + } + } + } + }`); + labels = labelsResponse.data.repository.labels.nodes; + } + + // Let's combine and flatten the information a bit + return { + // Project's title and URL are more for internal reporting purpose. + title: project.title, + url: project.url, + id: project.id, + + // Project's description should help us extract additional metadata: + // - the date of the breakout sessions + // - the timezone to use to interpret time slots + // - the "big meeting" value to associate calendar entries to TPAC + description: project.shortDescription, + metadata: parseProjectDescription(project.shortDescription), + + // List of rooms. For each of them, we return the exact name of the option + // for the "Room" custom field in the project. If the exact name can be + // split into a room label, capacity in number of seats, location, and the + // possible "vip" flag, then that information is used to initialize the + // room's metadata. The room's full name should follow the pattern: + // "label (xx - location) (vip)" + // Examples: + // Catalina (25) + // Utrecht (40 - 2nd floor) + // Main (120) (vip) + // Business (vip) + // Small (15) + // Plenary (150 - 18th floor) (vip) + // The exact same information can be provided using actual metadata in the + // description of the room, given as a list of key/value pairs such as: + // - capacity: 40 + // - location: 2nd floor + // Possible metadata keys are expected to evolve over time. If the + // information is duplicated in the room name and in metadata, the + // information in the room name will be used + roomsFieldId: rooms.id, + rooms: rooms.options.map(room => { + const metadata = {}; + (room.description ?? '') + .split(/\n/) + .map(line => line.trim().replace(/^[*\-] /, '').split(/:\s*/)) + .filter(data => data[0] && data[1]) + .filter(data => data[0].toLowerCase() !== 'capacity' || data[1]?.match(/^\d+$/)) + .forEach(data => metadata[data[0].toLowerCase()] = data[1]); + const match = room.name.match(/^(.*?)(?:\s*\((\d+)\s*(?:\-\s*([^\)]+))?\))?(?:\s*\((vip)\))?$/i); + return Object.assign(metadata, { + id: room.id, + name: match[0], + label: match[1], + location: match[3] ?? metadata.location ?? '', + capacity: parseInt(match[2] ?? metadata.capacity ?? '30', 10), + vip: !!match[4] || (metadata.vip === 'true') + }); + }), + + // IDs of custom fields used to store validation problems + severityFieldIds: severityFieldIds, + + // List of slots. For each of them, we return the exact name of the option + // for the "Slot" custom field in the project, the start and end times and + // the duration in minutes. + slotsFieldId: slots.id, + slots: slots.options.map(slot => { + const times = slot.name.match(/^(\d+):(\d+)\s*-\s*(\d+):(\d+)$/) ?? + [null, '00', '00', '01', '00']; + return { + id: slot.id, + name: slot.name, + start: `${times[1]}:${times[2]}`, + end: `${times[3]}:${times[4]}`, + duration: + (parseInt(times[3], 10) * 60 + parseInt(times[4], 10)) - + (parseInt(times[1], 10) * 60 + parseInt(times[2], 10)) + }; + }), + + // List of days. For single-day events, there will be only one day, and + // all sessions will be associated with it. + daysFieldId: days.id, + days: days.options.map(day => { + const match = + day.name.match(/(.*) \((\d{4}\-\d{2}\-\d{2})\)$/) ?? + [day.name, day.name, day.name]; + return { + id: day.id, + name: match[0], + label: match[1], + date: match[2] + }; + }), + + // ID of the "Meeting" custom field, if it exists + // (it signals the fact that sessions may be scheduled more than once) + meetingsFieldId: meeting?.id, + allowMultipleMeetings: !!meeting?.id, + + // ID of the "Try me out" custom field, if it exists + // (it signals the ability to try schedule adjustments from GitHub) + 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 + sessionTemplate: sessionTemplate, + sessionSections: getSessionSections(sessionTemplate), + + // List of open sessions linked to the project (in other words, all of the + // issues that have been associated with the project). For each session, we + // return detailed information, including its title, full body, author, + // labels, and the room and slot that may already have been assigned. + sessions: sessions + .filter(session => session.content.state === 'OPEN') + .map(session => { + return { + projectItemId: session.id, + id: session.content.id, + repository: session.content.repository.nameWithOwner, + number: session.content.number, + title: session.content.title, + body: session.content.body, + labels: session.content.labels.nodes.map(label => label.name), + author: { + databaseId: session.content.author.databaseId, + login: session.content.author.login + }, + room: session.fieldValues.nodes + .find(value => value.field?.name === 'Room')?.name, + day: session.fieldValues.nodes + .find(value => value.field?.name === 'Day')?.name, + slot: session.fieldValues.nodes + .find(value => value.field?.name === 'Slot')?.name, + meeting: session.fieldValues.nodes + .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, + error: session.fieldValues.nodes.find(value => value.field?.name === 'Error')?.text, + note: session.fieldValues.nodes.find(value => value.field?.name === 'Note')?.text + } + }; + }), + + // Labels defined in the associated repository + // (note all sessions should belong to the same repository!) + labels: labels + }; +} /** * Helper function to parse a project description and extract additional diff --git a/tools/common/session-sections.mjs b/tools/common/session-sections.mjs new file mode 100644 index 0000000..2c7a764 --- /dev/null +++ b/tools/common/session-sections.mjs @@ -0,0 +1,26 @@ +export function getSessionSections(template) { + const sessionSections = (template ? template.body : []) + .filter(section => !!section.id); + + // The "calendar" and "materials" sections are not part of the template. + // They are added manually or automatically when need arises. For the + // purpose of validation and serialization, we need to add them to the list + // of sections (as custom "auto hide" sections that only get displayed when + // they are not empty). + sessionSections.push({ + id: 'calendar', + attributes: { + label: 'Links to calendar', + autoHide: true + } + }); + sessionSections.push({ + id: 'materials', + attributes: { + label: 'Meeting materials', + autoHide: true + } + }); + + return sessionSections; +} \ No newline at end of file diff --git a/tools/common/wrappedfetch.mjs b/tools/common/wrappedfetch.mjs index 2312e80..dfa0506 100644 --- a/tools/common/wrappedfetch.mjs +++ b/tools/common/wrappedfetch.mjs @@ -7,13 +7,13 @@ export default async function (url, options) { params.method = options.method; } if (options.headers) { - params.header = options.headers; + params.headers = options.headers; } if (options.body) { params.payload = options.body; } - const response = UrlFetchApp.fetch(url) + const response = UrlFetchApp.fetch(url, params); return { status: response.getResponseCode(), json: async function () { diff --git a/tools/node/lib/project.mjs b/tools/node/lib/project.mjs index 0dfc96c..c2f8129 100644 --- a/tools/node/lib/project.mjs +++ b/tools/node/lib/project.mjs @@ -3,7 +3,9 @@ import { getEnvKey } from '../../common/envkeys.mjs'; import { readFile } from 'node:fs/promises'; import * as path from 'node:path'; import * as YAML from 'yaml'; -import { parseProjectDescription } from '../../common/project.mjs'; +import { + fetchProjectFromGitHub, + parseProjectDescription } from '../../common/project.mjs'; /** * Retrieve available project data. @@ -62,225 +64,6 @@ import { parseProjectDescription } from '../../common/project.mjs'; * } */ export async function fetchProject(login, id) { - // Login is an organization name... or starts with "user/" to designate - // a user project. - const tokens = login.split('/'); - const type = (tokens.length === 2) && tokens[0] === 'user' ? - 'user' : - 'organization'; - login = (tokens.length === 2) ? tokens[1] : login; - - // Retrieve information about the list of rooms - const roomsResponse = await sendGraphQLRequest(`query { - ${type}(login: "${login}"){ - projectV2(number: ${id}) { - id - url - title - shortDescription - field(name: "Room") { - ... on ProjectV2SingleSelectField { - id - name - options { - ... on ProjectV2SingleSelectFieldOption { - id - name - description - } - } - } - } - } - } - }`); - const project = roomsResponse.data[type].projectV2; - const rooms = project.field; - - // Similar request to list time slots - const slotsResponse = await sendGraphQLRequest(`query { - ${type}(login: "${login}"){ - projectV2(number: ${id}) { - field(name: "Slot") { - ... on ProjectV2SingleSelectField { - id - name - options { - ... on ProjectV2SingleSelectFieldOption { - id - name - } - } - } - } - } - } - }`); - const slots = slotsResponse.data[type].projectV2.field; - - // Similar request to list event days - const daysResponse = await sendGraphQLRequest(`query { - ${type}(login: "${login}"){ - projectV2(number: ${id}) { - field(name: "Day") { - ... on ProjectV2SingleSelectField { - id - name - options { - ... on ProjectV2SingleSelectFieldOption { - id - name - } - } - } - } - } - } - }`); - const days = daysResponse.data[type].projectV2.field; - - // Similar requests to get the ids of the custom fields used for validation - const severityFieldIds = {}; - for (const severity of ['Error', 'Warning', 'Check', 'Note']) { - const response = await sendGraphQLRequest(`query { - ${type}(login: "${login}"){ - projectV2(number: ${id}) { - field(name: "${severity}") { - ... on ProjectV2FieldCommon { - id - name - } - } - } - } - }`); - severityFieldIds[severity] = response.data[type].projectV2.field.id; - } - - // Project may also have a "Meeting" custom field when a session can be - // scheduled multiple times. The field contains the list of (room, day, slot) - // tuples that a session is associated with. - const meetingResponse = await sendGraphQLRequest(`query { - ${type}(login: "${login}"){ - projectV2(number: ${id}) { - field(name: "Meeting") { - ... on ProjectV2FieldCommon { - id - name - } - } - } - } - }`); - const meeting = meetingResponse.data[type].projectV2.field; - - // Project may also have a "Try me out" custom field to adjust the schedule - const tryMeetingResponse = await sendGraphQLRequest(`query { - ${type}(login: "${login}"){ - projectV2(number: ${id}) { - field(name: "Try me out") { - ... on ProjectV2FieldCommon { - id - name - } - } - } - } - }`); - 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}") { - projectV2(number: ${id}) { - items(first: 100) { - nodes { - id - content { - ... on Issue { - id - repository { - owner { - login - } - name - nameWithOwner - } - number - state - title - body - labels(first: 20) { - nodes { - name - } - } - author { - ... on User { - databaseId - } - login - } - } - } - fieldValues(first: 10) { - nodes { - ... on ProjectV2ItemFieldSingleSelectValue { - name - field { - ... on ProjectV2SingleSelectField { - name - } - } - } - ... on ProjectV2ItemFieldTextValue { - text - field { - ... on ProjectV2FieldCommon { - name - } - } - } - } - } - } - } - } - } - }`); - const sessions = sessionsResponse.data[type].projectV2.items.nodes; - - let labels = []; - if (sessions.length > 0) { - const repository = sessions[0].content.repository; - const labelsResponse = await sendGraphQLRequest(`query { - repository(owner: "${repository.owner.login}", name: "${repository.name}") { - labels(first: 50) { - nodes { - id - name - } - } - } - }`); - labels = labelsResponse.data.repository.labels.nodes; - } - // Time to read the issue template that goes with the project // TODO: the template file should rather be passed as function parameter! const templateDefault = path.join('.github', 'ISSUE_TEMPLATE', 'session.yml'); @@ -289,184 +72,7 @@ export async function fetchProject(login, id) { path.join(process.cwd(), templateFile), 'utf8'); const template = YAML.parse(templateYaml); - const sessionSections = template.body - .filter(section => !!section.id); - - // The "calendar" and "materials" sections are not part of the template. - // They are added manually or automatically when need arises. For the - // purpose of validation and serialization, we need to add them to the list - // of sections (as custom "auto hide" sections that only get displayed when - // they are not empty). - sessionSections.push({ - id: 'calendar', - attributes: { - label: 'Links to calendar', - autoHide: true - } - }); - sessionSections.push({ - id: 'materials', - attributes: { - label: 'Meeting materials', - autoHide: true - } - }); - - // Let's combine and flatten the information a bit - return { - // Project's title and URL are more for internal reporting purpose. - title: project.title, - url: project.url, - id: project.id, - - // Project's description should help us extract additional metadata: - // - the date of the breakout sessions - // - the timezone to use to interpret time slots - // - the "big meeting" value to associate calendar entries to TPAC - description: project.shortDescription, - metadata: parseProjectDescription(project.shortDescription), - - // List of rooms. For each of them, we return the exact name of the option - // for the "Room" custom field in the project. If the exact name can be - // split into a room label, capacity in number of seats, location, and the - // possible "vip" flag, then that information is used to initialize the - // room's metadata. The room's full name should follow the pattern: - // "label (xx - location) (vip)" - // Examples: - // Catalina (25) - // Utrecht (40 - 2nd floor) - // Main (120) (vip) - // Business (vip) - // Small (15) - // Plenary (150 - 18th floor) (vip) - // The exact same information can be provided using actual metadata in the - // description of the room, given as a list of key/value pairs such as: - // - capacity: 40 - // - location: 2nd floor - // Possible metadata keys are expected to evolve over time. If the - // information is duplicated in the room name and in metadata, the - // information in the room name will be used - roomsFieldId: rooms.id, - rooms: rooms.options.map(room => { - const metadata = {}; - (room.description ?? '') - .split(/\n/) - .map(line => line.trim().replace(/^[*\-] /, '').split(/:\s*/)) - .filter(data => data[0] && data[1]) - .filter(data => data[0].toLowerCase() !== 'capacity' || data[1]?.match(/^\d+$/)) - .forEach(data => metadata[data[0].toLowerCase()] = data[1]); - const match = room.name.match(/^(.*?)(?:\s*\((\d+)\s*(?:\-\s*([^\)]+))?\))?(?:\s*\((vip)\))?$/i); - return Object.assign(metadata, { - id: room.id, - name: match[0], - label: match[1], - location: match[3] ?? metadata.location ?? '', - capacity: parseInt(match[2] ?? metadata.capacity ?? '30', 10), - vip: !!match[4] || (metadata.vip === 'true') - }); - }), - - // IDs of custom fields used to store validation problems - severityFieldIds: severityFieldIds, - - // List of slots. For each of them, we return the exact name of the option - // for the "Slot" custom field in the project, the start and end times and - // the duration in minutes. - slotsFieldId: slots.id, - slots: slots.options.map(slot => { - const times = slot.name.match(/^(\d+):(\d+)\s*-\s*(\d+):(\d+)$/) ?? - [null, '00', '00', '01', '00']; - return { - id: slot.id, - name: slot.name, - start: `${times[1]}:${times[2]}`, - end: `${times[3]}:${times[4]}`, - duration: - (parseInt(times[3], 10) * 60 + parseInt(times[4], 10)) - - (parseInt(times[1], 10) * 60 + parseInt(times[2], 10)) - }; - }), - - // List of days. For single-day events, there will be only one day, and - // all sessions will be associated with it. - daysFieldId: days.id, - days: days.options.map(day => { - const match = - day.name.match(/(.*) \((\d{4}\-\d{2}\-\d{2})\)$/) ?? - [day.name, day.name, day.name]; - return { - id: day.id, - name: match[0], - label: match[1], - date: match[2] - }; - }), - - // ID of the "Meeting" custom field, if it exists - // (it signals the fact that sessions may be scheduled more than once) - meetingsFieldId: meeting?.id, - allowMultipleMeetings: !!meeting?.id, - - // ID of the "Try me out" custom field, if it exists - // (it signals the ability to try schedule adjustments from GitHub) - 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 - sessionTemplate: templateYaml, - sessionSections, - - // List of open sessions linked to the project (in other words, all of the - // issues that have been associated with the project). For each session, we - // return detailed information, including its title, full body, author, - // labels, and the room and slot that may already have been assigned. - sessions: sessions - .filter(session => session.content.state === 'OPEN') - .map(session => { - return { - projectItemId: session.id, - id: session.content.id, - repository: session.content.repository.nameWithOwner, - number: session.content.number, - title: session.content.title, - body: session.content.body, - labels: session.content.labels.nodes.map(label => label.name), - author: { - databaseId: session.content.author.databaseId, - login: session.content.author.login - }, - room: session.fieldValues.nodes - .find(value => value.field?.name === 'Room')?.name, - day: session.fieldValues.nodes - .find(value => value.field?.name === 'Day')?.name, - slot: session.fieldValues.nodes - .find(value => value.field?.name === 'Slot')?.name, - meeting: session.fieldValues.nodes - .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, - error: session.fieldValues.nodes.find(value => value.field?.name === 'Error')?.text, - note: session.fieldValues.nodes.find(value => value.field?.name === 'Note')?.text - } - }; - }), - - // Labels defined in the associated repository - // (note all sessions should belong to the same repository!) - labels: labels - }; + return fetchProjectFromGitHub(login, id, template); }