Skip to content

Commit f5c9dc4

Browse files
committed
Add Meetup event synchronization
1 parent 50e1a9f commit f5c9dc4

3 files changed

Lines changed: 215 additions & 0 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
2+
import path from 'node:path';
3+
4+
const API_URL = 'https://api.meetup.com/gql-ext';
5+
const GROUPS_FILE = path.join('data', 'meetup_groups.json');
6+
const CALENDAR_DIR = path.join('content', 'calendar');
7+
const DRY_RUN = process.argv.includes('--dry-run');
8+
9+
const QUERY = `
10+
query UpcomingGroupEvents($urlname: ID!) {
11+
group(urlname: $urlname) {
12+
name
13+
events(input: { first: 100, filter: { status: "UPCOMING" } }) {
14+
edges {
15+
node {
16+
id
17+
title
18+
description
19+
dateTime
20+
eventUrl
21+
type
22+
venue {
23+
name
24+
address
25+
city
26+
state
27+
country
28+
}
29+
}
30+
}
31+
}
32+
}
33+
}
34+
`;
35+
36+
function yaml(value) {
37+
return JSON.stringify(value ?? '');
38+
}
39+
40+
function plainText(html = '') {
41+
return html
42+
.replace(/<\/(?:p|div|li|h[1-6])>/gi, '\n')
43+
.replace(/<br\s*\/?\s*>/gi, '\n')
44+
.replace(/<[^>]*>/g, '')
45+
.replace(/&nbsp;/gi, ' ')
46+
.replace(/&amp;/gi, '&')
47+
.replace(/&lt;/gi, '<')
48+
.replace(/&gt;/gi, '>')
49+
.replace(/&quot;/gi, '"')
50+
.replace(/&#39;/gi, "'")
51+
.replace(/\n{3,}/g, '\n\n')
52+
.trim();
53+
}
54+
55+
function venueLabel(venue, groupName, isVirtual) {
56+
if (isVirtual) return 'Online';
57+
58+
const parts = [venue?.name, venue?.address, venue?.city, venue?.state, venue?.country]
59+
.filter(Boolean);
60+
return parts.length ? [...new Set(parts)].join(', ') : groupName;
61+
}
62+
63+
function isVirtual(event) {
64+
return ['ONLINE', 'HYBRID'].includes(event.type);
65+
}
66+
67+
function eventFile(event, groupName) {
68+
const virtual = isVirtual(event);
69+
const startDate = event.dateTime.slice(0, 10);
70+
const body = plainText(event.description);
71+
72+
return `---\nmeetupEventId: ${yaml(String(event.id))}\nmeetupSource: meetup\nstartDate: ${yaml(startDate)}\ntitle: ${yaml(event.title)}\nexternalUrl: ${yaml(event.eventUrl)}\nvirtual: ${virtual}\nwhere: ${yaml(venueLabel(event.venue, groupName, virtual))}\n---\n${body}\n`;
73+
}
74+
75+
async function groups() {
76+
const parsed = JSON.parse(await readFile(GROUPS_FILE, 'utf8'));
77+
if (!Array.isArray(parsed) || !parsed.every(({ urlname }) => typeof urlname === 'string' && urlname)) {
78+
throw new Error(`${GROUPS_FILE} must be an array of Meetup group objects with a urlname.`);
79+
}
80+
return parsed;
81+
}
82+
83+
async function fetchEvents(urlname, token) {
84+
const response = await fetch(API_URL, {
85+
method: 'POST',
86+
headers: {
87+
Authorization: `Bearer ${token}`,
88+
'Content-Type': 'application/json',
89+
},
90+
body: JSON.stringify({ query: QUERY, variables: { urlname } }),
91+
});
92+
93+
if (!response.ok) {
94+
throw new Error(`Meetup returned ${response.status} for ${urlname}.`);
95+
}
96+
97+
const result = await response.json();
98+
if (result.errors?.length) {
99+
throw new Error(`Meetup query failed for ${urlname}: ${result.errors.map(({ message }) => message).join('; ')}`);
100+
}
101+
if (!result.data?.group) {
102+
throw new Error(`Meetup group ${urlname} was not found or is not accessible to this token.`);
103+
}
104+
105+
return result.data.group;
106+
}
107+
108+
async function managedFiles() {
109+
const names = await readdir(CALENDAR_DIR);
110+
const files = await Promise.all(names.filter((name) => name.endsWith('.md')).map(async (name) => {
111+
const file = path.join(CALENDAR_DIR, name);
112+
const content = await readFile(file, 'utf8');
113+
return content.includes('meetupSource: meetup') ? file : null;
114+
}));
115+
return files.filter(Boolean);
116+
}
117+
118+
async function main() {
119+
const token = process.env.MEETUP_ACCESS_TOKEN;
120+
if (!token) {
121+
throw new Error('MEETUP_ACCESS_TOKEN is required. Create it from a Meetup OAuth client and store it as a repository secret.');
122+
}
123+
124+
const configuredGroups = await groups();
125+
const result = await Promise.all(configuredGroups.map(({ urlname }) => fetchEvents(urlname, token)));
126+
const desired = new Map();
127+
128+
for (const group of result) {
129+
for (const { node: event } of group.events.edges) {
130+
if (!event.id || !event.dateTime || !event.eventUrl || !event.title) {
131+
throw new Error(`Meetup event from ${group.name} is missing required calendar fields.`);
132+
}
133+
desired.set(path.join(CALENDAR_DIR, `meetup-${event.id}.md`), eventFile(event, group.name));
134+
}
135+
}
136+
137+
await mkdir(CALENDAR_DIR, { recursive: true });
138+
const existing = new Set(await managedFiles());
139+
const changes = [];
140+
141+
for (const [file, content] of desired) {
142+
let current;
143+
try {
144+
current = await readFile(file, 'utf8');
145+
} catch (error) {
146+
if (error.code !== 'ENOENT') throw error;
147+
}
148+
if (current !== content) {
149+
changes.push(`${current === undefined ? 'add' : 'update'} ${file}`);
150+
if (!DRY_RUN) await writeFile(file, content);
151+
}
152+
existing.delete(file);
153+
}
154+
155+
for (const file of existing) {
156+
changes.push(`remove ${file}`);
157+
if (!DRY_RUN) await rm(file);
158+
}
159+
160+
console.log(changes.length ? changes.join('\n') : 'Meetup events are already synchronized.');
161+
}
162+
163+
main().catch((error) => {
164+
console.error(error.message);
165+
process.exitCode = 1;
166+
});

.github/workflows/meetup-sync.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: Sync Meetup Events
2+
3+
on:
4+
schedule:
5+
- cron: '15 */6 * * *'
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: write
10+
pull-requests: write
11+
12+
concurrency:
13+
group: sync-meetup-events
14+
cancel-in-progress: false
15+
16+
jobs:
17+
sync-meetup-events:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- name: Checkout
21+
uses: actions/checkout@v4
22+
23+
- name: Setup Node.js
24+
uses: actions/setup-node@v4
25+
with:
26+
node-version: '20'
27+
28+
- name: Sync upcoming Meetup events
29+
env:
30+
MEETUP_ACCESS_TOKEN: ${{ secrets.MEETUP_ACCESS_TOKEN }}
31+
run: node .github/scripts/sync-meetup-events.mjs
32+
33+
- name: Create pull request for synchronized events
34+
uses: peter-evans/create-pull-request@v8.1.1
35+
with:
36+
add-paths: |
37+
content/calendar
38+
data/meetup_groups.json
39+
branch: automation/sync-meetup-events
40+
commit-message: Sync Meetup events
41+
title: Sync Meetup events
42+
body: |
43+
Automated synchronization of upcoming events from configured Meetup groups.
44+
delete-branch: true

data/meetup_groups.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[
2+
{
3+
"urlname": "research-triangle-powershell-users-group"
4+
}
5+
]

0 commit comments

Comments
 (0)