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
3 changes: 2 additions & 1 deletion cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,6 @@
"pnpm-lock.yaml",
".husky/_/**"
],
"ignoreRegExpList": ["Urls", "Email", "HexValues", "Base64"]
"ignoreRegExpList": ["Urls", "Email", "HexValues", "Base64"],
"words": ["CPPTORONTO", "urlnames"]
}
62 changes: 62 additions & 0 deletions packages/meetup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,68 @@ the API accepts no offset. So `event.date` has two possible readings, and the
Omitting `timezone` when your dates really are UTC silently shifts every event
by the zone's offset, so set it whenever your source stores real instants.

### Network events (one session, several groups)

A Meetup **Pro network** cross-posts the same session to every group in the
network. List the extra groups in `groups`, and give each its own host — groups
in a network almost always have different organizers:

```json
{
"meetup": {
"groupUrlname": "chicago-c-cpp-users-group",
"timezone": "America/Chicago",
"venues": { "online": "online" },
"groups": ["cpp-serbia", "CPPTORONTO"],
"hosts": { "Rob Douglas": 13296813, "Alex Smith": 256192100, "Jordan Lee": 274644230 },
"groupHosts": {
"chicago-c-cpp-users-group": ["Rob Douglas"],
"cpp-serbia": ["Alex Smith"],
"CPPTORONTO": ["Jordan Lee"]
}
}
}
```

`groupUrlname` is always the first target, then `groups` in order, de-duplicated
case-insensitively (Meetup urlnames are case-insensitive, and a network may
report `CPPTORONTO` where your config says `cpptoronto`).

```bash
# every group in the config
bunx coopkit-meetup create-from-json --groups all --dry-run event.json

# just some of them
bunx coopkit-meetup create-from-json --groups "cpp-serbia,CPPTORONTO" event.json
```

Without `--groups` only `groupUrlname` is used, and `--output` keeps its
original single-event shape — so existing setups are unaffected. With
`--groups`, `--output` gets a per-group breakdown instead:

```json
{
"status": "partial",
"groups": [
{ "groupUrlname": "chicago-c-cpp-users-group", "ok": true, "status": "created", "eventId": "1234", "eventUrl": "https://…", "photoAttached": false },
{ "groupUrlname": "cpp-serbia", "ok": false, "error": "createEvent returned no event. …" }
]
}
```

Groups are processed **sequentially**, and one group failing does not stop the
others — every outcome is reported so you can retry just the failures. The exit
code is 1 if any group failed. There is **no rollback**: drafts already created
stay, so a retry needs the failed groups named explicitly or it will duplicate
the ones that worked.

> **Why not `proNetworkEvents`?** `CreateEventInput` has a `proNetworkEvents`
> input that propagates one event across a network via a saved `filterId`. That
> filter cannot be enumerated through the API, so which groups it would reach is
> unverifiable from code. Naming the groups is explicit, and each draft can be
> reviewed or deleted on its own. Every account member must still have rights in
> each target group — being a Pro network admin is not by itself enough.

### Hosts

`eventHosts` takes Meetup **member IDs**, and the account must be a member of
Expand Down
99 changes: 86 additions & 13 deletions packages/meetup/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@
import fs from "node:fs";
import type { NormalizedEvent } from "@coopkit/core";
import { defineCommand, runMain } from "citty";
import { loadMeetupConfig, resolveHostIds } from "./config.js";
import { createMeetupDraft, createMeetupDraftFromFile } from "./create-event.js";
import { loadMeetupConfig, resolveGroupTargets, resolveHostIds } from "./config.js";
import {
type CreateMeetupDraftsResult,
createMeetupDraft,
createMeetupDraftFromFile,
createMeetupDrafts,
} from "./create-event.js";
import { formatVenueKey, listVenues } from "./list-venues.js";
import { loadEnvFile } from "./load-env.js";

Expand All @@ -12,20 +17,55 @@ import { loadEnvFile } from "./load-env.js";
* — notably GitHub Actions — can capture the created event's id/url. Keeps the
* package free of any Actions-specific coupling: it just emits JSON.
*/
/**
* Collapse per-group outcomes into one status: "partial" if any group failed,
* otherwise whatever the groups agree on ("dry-run" or "created"). Reporting a
* dry run as "created" would be a lie a caller might act on.
*/
function aggregateStatus(result: CreateMeetupDraftsResult): string {
if (result.results.some((r) => !r.ok)) return "partial";
const statuses = new Set(result.results.map((r) => (r.ok ? r.result.status : "failed")));
return statuses.size === 1 ? ([...statuses][0] ?? "created") : "mixed";
}

type SingleResult = {
status: string;
eventId?: string;
eventUrl?: string;
photoAttached?: boolean;
};

function summarizeSingle(result: SingleResult) {
return result.status === "created"
? {
status: result.status,
eventId: result.eventId ?? "",
eventUrl: result.eventUrl ?? "",
photoAttached: result.photoAttached ?? false,
}
: { status: result.status };
}

function writeResultFile(
outputPath: string | undefined,
result: { status: string; eventId?: string; eventUrl?: string; photoAttached?: boolean }
result: SingleResult | CreateMeetupDraftsResult
): void {
if (!outputPath) return;

// A multi-group run has no single id/url to report, so emit the per-group
// breakdown instead of flattening it to one (arbitrary) event.
const payload =
result.status === "created"
"results" in result
? {
status: result.status,
eventId: result.eventId ?? "",
eventUrl: result.eventUrl ?? "",
photoAttached: result.photoAttached ?? false,
status: aggregateStatus(result),
groups: result.results.map((r) =>
r.ok
? { groupUrlname: r.groupUrlname, ok: true, ...summarizeSingle(r.result) }
: { groupUrlname: r.groupUrlname, ok: false, error: r.error }
),
}
: { status: result.status };
: summarizeSingle(result);

fs.writeFileSync(outputPath, `${JSON.stringify(payload)}\n`);
}

Expand Down Expand Up @@ -137,6 +177,11 @@ const createFromJsonCmd = defineCommand({
description:
"Host name(s) from meetup.hosts, comma-separated. Defaults to meetup.defaultHosts.",
},
groups: {
type: "string",
description:
'Group urlname(s) to create the event in, comma-separated, or "all" for every group in the config. Defaults to meetup.groupUrlname only.',
},
output: {
type: "string",
description:
Expand All @@ -148,16 +193,44 @@ const createFromJsonCmd = defineCommand({
const config = loadMeetupConfig(args.config);
const raw = args.file ? fs.readFileSync(args.file, "utf8") : readStdin();
const event = parseNormalizedEvent(raw);
const hosts = resolveHostIds(config, parseHostArg(args.host));
const result = await createMeetupDraft({
const hostNames = parseHostArg(args.host);
const requested = parseHostArg(args.groups);
const multiGroup = requested !== undefined;
const only =
requested && !(requested.length === 1 && requested[0]?.toLowerCase() === "all")
? requested
: undefined;
const targets = resolveGroupTargets(config, {
...(only !== undefined ? { only } : {}),
...(hostNames !== undefined ? { hostNames } : {}),
});

// Single-group runs keep returning the bare result, so existing callers
// parsing --output see no change; --groups opts into the per-group report.
if (!multiGroup) {
const target = targets[0];
if (!target) throw new Error("No group resolved from the config.");
const result = await createMeetupDraft({
event,
groupUrlname: target.urlname,
venues: config.venues,
...(config.timezone !== undefined ? { timezone: config.timezone } : {}),
...(target.hosts.length > 0 ? { hosts: target.hosts } : {}),
dryRun: Boolean(args["dry-run"]),
});
writeResultFile(args.output, result);
return;
}

const result = await createMeetupDrafts({
event,
groupUrlname: config.groupUrlname,
groups: targets,
venues: config.venues,
...(config.timezone !== undefined ? { timezone: config.timezone } : {}),
...(hosts.length > 0 ? { hosts } : {}),
dryRun: Boolean(args["dry-run"]),
});
writeResultFile(args.output, result);
if (result.results.some((r) => !r.ok)) process.exitCode = 1;
},
});

Expand Down
112 changes: 111 additions & 1 deletion packages/meetup/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { describe, expect, it } from "bun:test";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { type MeetupConfig, loadMeetupConfig, resolveHostIds } from "./config.js";
import {
type MeetupConfig,
loadMeetupConfig,
resolveGroupTargets,
resolveHostIds,
} from "./config.js";

function writeConfig(contents: unknown): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "coopkit-config-"));
Expand Down Expand Up @@ -89,3 +94,108 @@ describe("resolveHostIds", () => {
expect(() => resolveHostIds(cfg, ["Nobody"])).toThrow(/Known hosts: "Rob Douglas"/);
});
});

describe("loadMeetupConfig groups/groupHosts", () => {
it("loads groups and groupHosts", () => {
const cfg = loadMeetupConfig(
writeConfig({
meetup: {
...BASE,
hosts: { "Rob Douglas": 13296813 },
groups: ["cpp-serbia"],
groupHosts: { "chicago-c-cpp-users-group": ["Rob Douglas"] },
},
})
);
expect(cfg.groups).toEqual(["cpp-serbia"]);
expect(cfg.groupHosts).toEqual({ "chicago-c-cpp-users-group": ["Rob Douglas"] });
});

it("rejects a non-array groups", () => {
expect(() => loadMeetupConfig(writeConfig({ meetup: { ...BASE, groups: "a" } }))).toThrow(
/meetup.groups must be an array/
);
});

it("rejects a groupHosts name that is not in hosts", () => {
expect(() =>
loadMeetupConfig(
writeConfig({
meetup: { ...BASE, hosts: { Rob: 1 }, groupHosts: { "some-group": ["Nope"] } },
})
)
).toThrow(/groupHosts\["some-group"\] entry "Nope" is not a key of meetup.hosts/);
});
});

describe("resolveGroupTargets", () => {
const cfg: MeetupConfig = {
groupUrlname: "chicago-c-cpp-users-group",
venues: { online: "online" },
hosts: { "Rob Douglas": 13296813, "Alex Smith": 256192100, "Jordan Lee": 274644230 },
defaultHosts: ["Rob Douglas"],
groups: ["cpp-serbia", "CPPTORONTO"],
groupHosts: {
"chicago-c-cpp-users-group": ["Rob Douglas"],
"cpp-serbia": ["Alex Smith"],
cpptoronto: ["Jordan Lee"],
},
};

it("puts groupUrlname first, then groups in order", () => {
expect(resolveGroupTargets(cfg).map((t) => t.urlname)).toEqual([
"chicago-c-cpp-users-group",
"cpp-serbia",
"CPPTORONTO",
]);
});

it("resolves per-group hosts, matching keys case-insensitively", () => {
const targets = resolveGroupTargets(cfg);
expect(targets.map((t) => t.hosts)).toEqual([[13296813], [256192100], [274644230]]);
});

it("falls back to defaultHosts for a group with no override", () => {
const targets = resolveGroupTargets({ ...cfg, groupHosts: {} });
expect(targets.every((t) => t.hosts[0] === 13296813)).toBe(true);
});

it("de-duplicates groupUrlname repeated in groups, case-insensitively", () => {
const targets = resolveGroupTargets({
...cfg,
groups: ["CHICAGO-C-CPP-USERS-GROUP", "cpp-serbia"],
});
expect(targets.map((t) => t.urlname)).toEqual(["chicago-c-cpp-users-group", "cpp-serbia"]);
});

it("narrows to the requested groups", () => {
expect(resolveGroupTargets(cfg, { only: ["cpp-serbia"] }).map((t) => t.urlname)).toEqual([
"cpp-serbia",
]);
});

it("matches the `only` filter case-insensitively", () => {
expect(resolveGroupTargets(cfg, { only: ["cpptoronto"] }).map((t) => t.urlname)).toEqual([
"CPPTORONTO",
]);
});

it("throws when a requested group is not in the config", () => {
expect(() => resolveGroupTargets(cfg, { only: ["winnipeg-cpp"] })).toThrow(
/"winnipeg-cpp" are not in this config/
);
});

it("returns just the one group when no groups list is configured", () => {
const targets = resolveGroupTargets({
groupUrlname: "solo-group",
venues: { online: "online" },
});
expect(targets).toEqual([{ urlname: "solo-group", hosts: [] }]);
});

it("lets an explicit hostNames override apply to groups without a groupHosts entry", () => {
const targets = resolveGroupTargets({ ...cfg, groupHosts: {} }, { hostNames: ["Alex Smith"] });
expect(targets.every((t) => t.hosts[0] === 256192100)).toBe(true);
});
});
Loading
Loading