Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions packages/meetup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ Drop a `coopkit.config.json` at the repo root:
| --- | --- | --- |
| `groupUrlname` | yes | Meetup group slug the drafts are created in. |
| `venues` | yes | Maps frontmatter venue keys to Meetup venue IDs. |
| `timezone` | no | IANA zone of the group. **Set this if your event dates are true UTC** — see below. |
| `timezone` | no | IANA zone, for a **single**-group config. **Set this if your event dates are true UTC** — see below. |
| `groupTimezones` | for multi-group | Per-group IANA zone. Required once more than one group is targeted. |
| `hosts` | no | Named Meetup member IDs, so you can refer to hosts by name. |
| `defaultHosts` | no | Names from `hosts` used when an event names none. |

Expand Down Expand Up @@ -102,9 +103,13 @@ in a network almost always have different organizers:
{
"meetup": {
"groupUrlname": "chicago-c-cpp-users-group",
"timezone": "America/Chicago",
"venues": { "online": "online" },
"groups": ["cpp-serbia", "CPPTORONTO"],
"groupTimezones": {
"chicago-c-cpp-users-group": "America/Chicago",
"cpp-serbia": "Europe/Belgrade",
"CPPTORONTO": "America/Toronto"
},
"hosts": { "Rob Douglas": 13296813, "Alex Smith": 256192100, "Jordan Lee": 274644230 },
"groupHosts": {
"chicago-c-cpp-users-group": ["Rob Douglas"],
Expand All @@ -119,6 +124,21 @@ in a network almost always have different organizers:
case-insensitively (Meetup urlnames are case-insensitive, and a network may
report `CPPTORONTO` where your config says `cpptoronto`).

> **`groupTimezones` is required for a multi-group run, and this is not a style
> preference.** Meetup has no single "event time": `startDateTime` is wall time in
> the *receiving group's* own zone, with no offset accepted. Send one group's wall
> time to every group and you create a **different instant in each** — a session at
> 16:00Z becomes 11:00 in Chicago and also 11:00 in Belgrade, seven hours apart.
> A multi-group run with only the shared `timezone` therefore **fails** rather than
> silently placing it at the wrong time. Run `coopkit-meetup list-groups` to print the map:
>
> ```bash
> bunx coopkit-meetup list-groups
> ```
>
> `timezone` remains valid for a single-group config, and a narrowed run
> (`--groups cpp-serbia`) still accepts it.

```bash
# every group in the config
bunx coopkit-meetup create-from-json --groups all --dry-run event.json
Expand Down Expand Up @@ -251,6 +271,16 @@ No writeback — the JSON path is for one-shot creation. Adopters who need bookk
bunx coopkit-meetup list-venues
```

### List groups (timezones)

```bash
bunx coopkit-meetup list-groups
```

Prints each configured group's name and the timezone Meetup holds for it, plus a
ready-to-paste `groupTimezones` map. A group that cannot be read is listed with
`(unknown)` and warned about rather than aborting the listing.

## Library API

```ts
Expand Down
77 changes: 77 additions & 0 deletions packages/meetup/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,80 @@ describe("create-from-json --output (dry-run)", () => {
}
});
});

// A multi-group config. Each group needs its own timezone, or resolveGroupTargets
// refuses the run -- see "per-group timezones" in config.test.ts.
const MULTI_CONFIG = JSON.stringify({
meetup: {
groupUrlname: "cpp-serbia",
groups: ["chicago-c-cpp-users-group"],
venues: { online: 42 },
groupTimezones: {
"cpp-serbia": "Europe/Belgrade",
"chicago-c-cpp-users-group": "America/Chicago",
},
},
});

/** Same event, but pointing at a venue key the config does not define. */
const EVENT_BAD_VENUE = JSON.stringify({ ...JSON.parse(EVENT), venueKey: "nope" });

describe("exit codes", () => {
it("exits 1 when a group in a multi-group run fails", async () => {
const dir = scratch();
try {
const config = join(dir, "coopkit.config.json");
const event = join(dir, "event.json");
const out = join(dir, "result.json");
writeFileSync(config, MULTI_CONFIG);
// An unknown venue throws inside the per-group try, so every group fails
// while the run itself completes -- no network, no credentials needed.
writeFileSync(event, EVENT_BAD_VENUE);

const { code } = await run([
"create-from-json",
"--groups",
"all",
"--dry-run",
"--config",
config,
"--output",
out,
event,
]);

expect(code).toBe(1);
expect(JSON.parse(readFileSync(out, "utf8")).status).toBe("partial");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("exits 0 when every group in a multi-group run succeeds", async () => {
const dir = scratch();
try {
const config = join(dir, "coopkit.config.json");
const event = join(dir, "event.json");
const out = join(dir, "result.json");
writeFileSync(config, MULTI_CONFIG);
writeFileSync(event, EVENT);

const { code } = await run([
"create-from-json",
"--groups",
"all",
"--dry-run",
"--config",
config,
"--output",
out,
event,
]);

expect(code).toBe(0);
expect(JSON.parse(readFileSync(out, "utf8")).status).toBe("dry-run");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
57 changes: 51 additions & 6 deletions packages/meetup/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
createMeetupDraftFromFile,
createMeetupDrafts,
} from "./create-event.js";
import { listGroups } from "./list-groups.js";
import { formatVenueKey, listVenues } from "./list-venues.js";
import { loadEnvFile } from "./load-env.js";

Expand Down Expand Up @@ -123,15 +124,14 @@ const createCmd = defineCommand({
eventFile: args.eventFile,
groupUrlname: target.urlname,
venues: config.venues,
...(config.timezone !== undefined ? { timezone: config.timezone } : {}),
...((target.timezone ?? config.timezone) !== undefined
? { timezone: (target.timezone ?? config.timezone) as string }
: {}),
...(target.hosts.length > 0 ? { hosts: target.hosts } : {}),
includeSpeaker: target.includeSpeaker,
dryRun: Boolean(args["dry-run"]),
});
writeResultFile(args.output, result);
if (result.status === "skipped") {
process.exit(0);
}
},
});

Expand Down Expand Up @@ -220,7 +220,9 @@ const createFromJsonCmd = defineCommand({
event,
groupUrlname: target.urlname,
venues: config.venues,
...(config.timezone !== undefined ? { timezone: config.timezone } : {}),
...((target.timezone ?? config.timezone) !== undefined
? { timezone: (target.timezone ?? config.timezone) as string }
: {}),
...(target.hosts.length > 0 ? { hosts: target.hosts } : {}),
includeSpeaker: target.includeSpeaker,
dryRun: Boolean(args["dry-run"]),
Expand All @@ -229,6 +231,8 @@ const createFromJsonCmd = defineCommand({
return;
}

// `targets` carry a per-group timezone; config.timezone stays only as the
// fallback for a single-group config that names no groupTimezones.
const result = await createMeetupDrafts({
event,
groups: targets,
Expand Down Expand Up @@ -305,6 +309,42 @@ const listVenuesCmd = defineCommand({
},
});

const listGroupsCmd = defineCommand({
meta: {
name: "list-groups",
description:
"Show each configured group's timezone and print a ready-to-paste groupTimezones map.",
},
args: {
config: {
type: "string",
description: "Path to coopkit.config.json (default: ./coopkit.config.json).",
},
},
async run({ args }) {
loadEnvFile();
const config = loadMeetupConfig(args.config);
const urlnames = [config.groupUrlname, ...(config.groups ?? [])];
const groups = await listGroups({ urlnames });

for (const g of groups) {
console.error(` ${g.urlname} ${g.name ?? "(unreadable)"} ${g.timezone ?? "(unknown)"}`);
}

const unknown = groups.filter((g) => !g.timezone).map((g) => g.urlname);
if (unknown.length > 0) {
console.error(
`\n[warn] No timezone for: ${unknown.join(", ")}. Check the urlname and that the account can read the group.`
);
}

console.error("\n--- Suggested `meetup.groupTimezones` for coopkit.config.json ---\n");
const map: Record<string, string> = {};
for (const g of groups) if (g.timezone) map[g.urlname] = g.timezone;
console.log(JSON.stringify({ groupTimezones: map }, null, 2));
},
});

const main = defineCommand({
meta: {
name: "coopkit-meetup",
Expand All @@ -314,7 +354,12 @@ const main = defineCommand({
create: createCmd,
"create-from-json": createFromJsonCmd,
"list-venues": listVenuesCmd,
"list-groups": listGroupsCmd,
},
});

runMain(main).then(() => process.exit(0));
// Exit explicitly: the HTTP client uses global fetch, whose keep-alive sockets
// keep the process alive for seconds after the last call, which reads as a hang
// on CI. No argument, so a process.exitCode set by a handler (a partial
// multi-group failure) survives instead of being overwritten with 0.
runMain(main).then(() => process.exit());
81 changes: 81 additions & 0 deletions packages/meetup/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,3 +242,84 @@ describe("speakerDetails gating", () => {
).toThrow(/speakerDetails must be true\/false or an array of group urlnames/);
});
});

describe("per-group timezones", () => {
const NETWORK: MeetupConfig = {
groupUrlname: "chicago-c-cpp-users-group",
venues: { online: "online" },
groups: ["cpp-serbia", "CPPTORONTO"],
groupTimezones: {
"chicago-c-cpp-users-group": "America/Chicago",
"cpp-serbia": "Europe/Belgrade",
CPPTORONTO: "America/Toronto",
},
};

it("gives every group its own timezone", () => {
expect(resolveGroupTargets(NETWORK).map((t) => [t.urlname, t.timezone])).toEqual([
["chicago-c-cpp-users-group", "America/Chicago"],
["cpp-serbia", "Europe/Belgrade"],
["CPPTORONTO", "America/Toronto"],
]);
});

it("matches groupTimezones keys case-insensitively", () => {
const targets = resolveGroupTargets({
...NETWORK,
groups: ["cpptoronto"],
groupTimezones: {
"chicago-c-cpp-users-group": "America/Chicago",
CPPTORONTO: "America/Toronto",
},
});
expect(targets.find((t) => t.urlname === "cpptoronto")?.timezone).toBe("America/Toronto");
});

it("refuses a multi-group run that has only one shared timezone", () => {
// The bug this guards: one wall time sent to groups in different zones is a
// different instant in each.
expect(() =>
resolveGroupTargets({
groupUrlname: "chicago-c-cpp-users-group",
venues: { online: "online" },
groups: ["cpp-serbia"],
timezone: "America/Chicago",
})
).toThrow(/cannot describe a multi-group run/);
});

it("names the groups missing a timezone", () => {
expect(() =>
resolveGroupTargets({
...NETWORK,
timezone: "America/Chicago",
groupTimezones: { "chicago-c-cpp-users-group": "America/Chicago" },
})
).toThrow(/"cpp-serbia", "CPPTORONTO"/);
});

it("allows a single-group run with only the shared timezone", () => {
const targets = resolveGroupTargets({
groupUrlname: "solo-group",
venues: { online: "online" },
timezone: "America/Chicago",
});
expect(targets[0]?.timezone).toBe("America/Chicago");
});

it("allows a narrowed single-group run even when the config is multi-group", () => {
const targets = resolveGroupTargets(
{ ...NETWORK, groupTimezones: undefined, timezone: "Europe/Belgrade" },
{ only: ["cpp-serbia"] }
);
expect(targets.map((t) => [t.urlname, t.timezone])).toEqual([
["cpp-serbia", "Europe/Belgrade"],
]);
});

it("rejects a malformed groupTimezones value", () => {
expect(() =>
loadMeetupConfig(writeConfig({ meetup: { ...BASE, groupTimezones: { "a-group": 5 } } }))
).toThrow(/must be a non-empty IANA timezone string/);
});
});
Loading
Loading