├── TODO.md ├── package.json ├── LICENSE ├── README.md ├── cli.js ├── .gitignore └── index.js /TODO.md: -------------------------------------------------------------------------------- 1 | 2 | # Todo 3 | 4 | - [x] Make a template of agenda 5 | - [x] List the data that needs to be extracted 6 | - [x] Fetch calendar to get next date + call details 7 | - [x] Fetch issues for both plenary and committees, filter by label, and generate per committee 8 | - [ ] Upload to repo 9 | - [x] Propose a policy 10 | - Only GH issues can be on the agenda 11 | - To be discussed at the next meeting, they need to have `board agenda` labels 12 | - They need to be either `for discussion` or `needs resolution` 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "diotima", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "description": "Agenda management for the W3C Board of Directors", 6 | "author": "Robin Berjon ", 7 | "license": "MIT", 8 | "scripts": {}, 9 | "bin": { 10 | "diotima": "./cli.js" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/darobin/diotima.git" 15 | }, 16 | "eslintConfig": { 17 | "env": { 18 | "browser": true, 19 | "mocha": true, 20 | "es2021": true 21 | }, 22 | "extends": "eslint:recommended", 23 | "overrides": [], 24 | "parserOptions": { 25 | "ecmaVersion": "latest", 26 | "sourceType": "module" 27 | }, 28 | "rules": {} 29 | }, 30 | "devDependencies": { 31 | "eslint": "^8.26.0" 32 | }, 33 | "dependencies": { 34 | "axios": "^1.3.4", 35 | "commander": "^10.0.0", 36 | "ical2json": "^3.1.2", 37 | "keytar": "^7.9.0", 38 | "linkifyjs": "^4.1.0", 39 | "octokit": "^2.0.14" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Robin Berjon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # diotima 3 | 4 | Agenda management for the W3C Board of Directors. 5 | 6 | > and turning rather towards the main ocean of the beautiful may by contemplation of this bring forth in all their 7 | > splendor many fair fruits of discourse and meditation in a plenteous crop of philosophy; until with the strength 8 | > and increase there acquired he descries a certain single knowledge connected with a beauty which has yet to be told.\ 9 | > — *Diotima*, cited in *Symposium*, Plato 10 | 11 | ## Installation 12 | 13 | Clone the repo and `npm install -g`. 14 | 15 | ## Usage 16 | 17 | Before you use it, it needs to be set up with the right secrets. Diotima uses your platform's keychain system to 18 | securely store and retrieve your secrets. 19 | 20 | First, create a GitHub token with an account that has access to the BoD's org on GitHub, then: 21 | 22 | ``` 23 | diotima token YOUR_TOKEN 24 | ``` 25 | 26 | Second, get your W3C calendar URL (from an account that is invited to BoD events) and make sure to exclude cancelled 27 | events, then: 28 | 29 | ``` 30 | diotima calendar CALENDAR_URL 31 | ``` 32 | 33 | You only need to do that once; afterwards you can generate an agenda any time with: 34 | 35 | ``` 36 | diotima agenda > path/to/agenda.md 37 | ``` 38 | 39 | ## Conventions 40 | 41 | The conventions that it understands are these, please use them: 42 | 43 | - Issues in the board's repo or any committee repo that are labelled 'board agenda' get included in the agenda for the next meeting. 44 | - Ideally those issues should also be labelled 'for discussion' or 'needs resolution' so that we can know what to expect. 45 | - The title of the issue is used for the agenda entry, and the body is used for the description, please use those accordingly. 46 | - If the issue is assigned to someone, that person will be listed as expected to lead the conversation. 47 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import process from "node:process"; 4 | import { program } from "commander"; 5 | import { makeRel, loadJSON, getToken, setToken, getCalendar, setCalendar, generateAgenda } from "./index.js"; 6 | 7 | const rel = makeRel(import.meta.url); 8 | const { version } = await loadJSON(rel('./package.json')); 9 | 10 | program.version(version); 11 | 12 | program 13 | .command('token ') 14 | .description('Set your GitHub token from https://github.com/settings/tokens/new to authenticate') 15 | .action(async (token) => { 16 | try { 17 | await setToken(token); 18 | console.warn(`Token set successfully.`); 19 | } 20 | catch (err) { 21 | console.warn(`Failed to set token:`, err.message); 22 | } 23 | }) 24 | ; 25 | 26 | program 27 | .command('calendar ') 28 | .description('Set your calendar URL (exclusing cancelled events) from your W3C account') 29 | .action(async (url) => { 30 | try { 31 | await setCalendar(url); 32 | console.warn(`Calendar set successfully.`); 33 | } 34 | catch (err) { 35 | console.warn(`Failed to set calendar:`, err.message); 36 | } 37 | }) 38 | ; 39 | 40 | program 41 | .command('agenda') 42 | .description('Generate the agenda for the next Board meeting') 43 | .action(async () => { 44 | try { 45 | const githubToken = await getToken(); 46 | if (!githubToken) throw new Error(`No token found, you need to run 'diotima token ' first.`); 47 | const w3cCalendar = await getCalendar(); 48 | if (!w3cCalendar) throw new Error(`No calendar found, you need to run 'diotima calendar ' first.`); 49 | await generateAgenda({ githubToken, w3cCalendar }); 50 | console.warn(`Agenda generated successfully.`); 51 | } 52 | catch (err) { 53 | console.warn(`Failed to generate agenda:`, err.message); 54 | } 55 | }) 56 | ; 57 | 58 | 59 | program.parseAsync(process.argv); 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | tokens.js 106 | test.md 107 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 2 | import { readFile } from 'node:fs/promises'; 3 | import keytar from "keytar"; 4 | import { Octokit } from 'octokit'; 5 | import axios from 'axios'; 6 | import ical2json from 'ical2json'; 7 | import { find } from 'linkifyjs'; 8 | 9 | const SERVICE = 'com.berjon.diotima'; 10 | const GH_ACCOUNT = 'w3c-bod-gitub'; 11 | const CAL_ACCOUNT = 'w3c-bod-calendar'; 12 | const ONE_AND_A_HALF_DAYS = 36 * 60 * 60 * 1000; // in case DST or start time shifts 13 | const DATE_FMT = { weekday: 'long', year: 'numeric', month: 'long', day: '2-digit' }; 14 | const TIME_FMT = { hour: "2-digit", minute: "2-digit", hour12: false }; 15 | const REPOS = ['finance', 'personnel', 'governance', 'board']; 16 | const REPO_LABELS = { 17 | finance: 'Finance Committee', 18 | personnel: 'Personnel Committee', 19 | governance: 'Governance Committee', 20 | board: 'Board', 21 | }; 22 | 23 | 24 | // call with makeRel(import.meta.url), returns a function that resolves relative paths 25 | export function makeRel (importURL) { 26 | return (pth) => new URL(pth, importURL).toString().replace(/^file:\/\//, ''); 27 | } 28 | 29 | export async function loadJSON (url) { 30 | const data = await readFile(url); 31 | return new Promise((resolve, reject) => { 32 | try { 33 | resolve(JSON.parse(data)); 34 | } 35 | catch (err) { 36 | reject(err); 37 | } 38 | }); 39 | } 40 | 41 | export async function getToken () { 42 | return keytar.getPassword(SERVICE, GH_ACCOUNT); 43 | } 44 | export async function setToken (tok) { 45 | return keytar.setPassword(SERVICE, GH_ACCOUNT, tok); 46 | } 47 | export async function getCalendar () { 48 | return keytar.getPassword(SERVICE, CAL_ACCOUNT); 49 | } 50 | export async function setCalendar (tok) { 51 | return keytar.setPassword(SERVICE, CAL_ACCOUNT, tok); 52 | } 53 | 54 | export async function generateAgenda ({ githubToken, w3cCalendar }) { 55 | const ev = await getNextMeeting(w3cCalendar); 56 | const issues = await getGitHubIssues(githubToken); 57 | let date; 58 | if (ev.multiDay) { 59 | date = 'Days:\n'; 60 | ev.startDate.forEach((sd, i) => { 61 | date += `* ${toDate(sd, ev.endDate[i])}` 62 | }); 63 | } 64 | else { 65 | date = `${toDate(ev.startDate, ev.endDate)} UTC`; 66 | } 67 | console.log(` 68 | # Meeting Agenda — W3C, Inc. Board of Directors 69 | 70 | ${date} 71 | 72 | Regrets: @@@ 73 | 74 | ## Agenda Review 75 | 76 | Changes to the agenda, if necessary. 77 | 78 | ## Minutes Approval 79 | 80 | @@@ link to previous minutes 81 | `); 82 | REPOS.forEach(repo => { 83 | if (!issues[repo]?.length) { 84 | console.log(`## No ${REPO_LABELS[repo]} Issues\n\nNothing discussed at this meeting.\n`); 85 | } 86 | else { 87 | console.log(`## ${REPO_LABELS[repo]}\n\n`); 88 | issues[repo].forEach(iss => console.log(formatIssue(iss, 3))); 89 | } 90 | }); 91 | } 92 | 93 | async function getGitHubIssues (auth) { 94 | if (!auth) throw new Error('GitHub client needs a token'); 95 | const gh = new Octokit({ auth }); 96 | const issues = {}; 97 | for (const repo of REPOS) { 98 | if (!issues[repo]) issues[repo] = []; 99 | const res = await gh.rest.issues.listForRepo({ 100 | repo, 101 | owner: 'w3c-bod', 102 | labels: 'board agenda', 103 | }); 104 | if (res.status !== 200) throw new Error('Failed to load issues'); 105 | issues[repo] = res.data; 106 | } 107 | // console.log(JSON.stringify(issues, null, 2)); 108 | return issues; 109 | } 110 | 111 | async function getNextMeeting (w3cCalendar) { 112 | const res = await axios.get(w3cCalendar); 113 | if (res.status >= 400) throw new Error(`Could not get W3C BoD Calendar`, res.statusText); 114 | const data = await res.data; 115 | const json = ical2json.convert(data); 116 | const today = new Date().toISOString().replace(/-/g, ''); 117 | const events = json 118 | .VCALENDAR[0] 119 | .VEVENT 120 | .filter(ev => /^Board\s+of\s+Directors/i.test(ev.SUMMARY)) 121 | .map(ev => { 122 | const name = ev.SUMMARY; 123 | const description = ev.DESCRIPTION; 124 | let startDate, endDate; 125 | Object.keys(ev).forEach(k => { 126 | if (/^DTSTART/.test(k)) startDate = ev[k]; 127 | if (/^DTEND/.test(k)) endDate = ev[k]; 128 | }); 129 | const links = find(description || '') || []; 130 | const joinInfo = links.filter(lnk => /\.zoom\.us/i.test(lnk.href))?.href; 131 | return { name, description, startDate, endDate, joinInfo }; 132 | }) 133 | .filter(({ startDate }) => startDate > today) 134 | .sort((a, b) => a.startDate.localeCompare(b.startDate)) 135 | ; 136 | const nextMeeting = events.shift(); 137 | if (!nextMeeting) return; 138 | let lastStartDate = toms(nextMeeting); 139 | while (events[0] && (toms(events[0]) - lastStartDate) <= ONE_AND_A_HALF_DAYS) { 140 | const ev = events.shift(); 141 | nextMeeting.multiDay = true; 142 | if (!Array.isArray(nextMeeting.startDate)) { 143 | nextMeeting.startDate = [nextMeeting.startDate]; 144 | nextMeeting.endDate = [nextMeeting.endDate]; 145 | } 146 | nextMeeting.startDate.push(ev.startDate); 147 | nextMeeting.endDate.push(ev.endDate); 148 | lastStartDate = toms(ev); 149 | } 150 | return nextMeeting; 151 | } 152 | 153 | function toms (ev) { 154 | return Date.parse(ev.startDate.replace(/T.*/, '')); 155 | } 156 | 157 | function toDate (startDate, endDate) { 158 | const sd = parseVDate(startDate); 159 | const ed = parseVDate(endDate); 160 | return `${sd.toLocaleDateString('en-US', DATE_FMT)} ${sd.toLocaleTimeString([], TIME_FMT)} - ${ed.toLocaleTimeString([], TIME_FMT)}`; 161 | } 162 | 163 | // 20230306T220000Z 164 | function parseVDate (str) { 165 | const [, year, month, day, hours, minutes] = str.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/); 166 | return new Date(year, parseInt(month, 10) - 1, day, hours, minutes); 167 | } 168 | 169 | function formatIssue ({ title, number, body, html_url, labels, assignees }, depth = 2) { 170 | let kind = 'UNKNOWN: for discussion or needs resolution'; 171 | if (labels.find(({ name }) => name === 'for discussion')) kind = 'For discussion.'; 172 | else if (labels.find(({ name }) => name === 'needs resolution')) kind = 'Needs resolution.'; 173 | const leaders = assignees.map(({ login, html_url, avatar_url }) => `![${login}](${smol(avatar_url)}) [@${login}](${html_url})`).join(', '); 174 | return `${'#'.repeat(depth)} ${title} ([#${number}](${html_url})) 175 | **${kind}** 176 | 177 | ${body || 'No description.'}${leaders ? `\n\nDiscussion led by: ${leaders}`: ''} 178 | `; 179 | } 180 | 181 | function smol (avatar_url) { 182 | return `${avatar_url}${/\?/.test(avatar_url) ? '&' : '?'}size=32`; 183 | } 184 | --------------------------------------------------------------------------------