Skip to content
Open
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
32 changes: 32 additions & 0 deletions packages/core/src/frontmatter.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
/**
* One event as published on an external platform, for bookkeeping in an
* adopter's source file.
*
* Platform-neutral: `group` is whatever the platform calls a community (a
* Meetup group urlname, a Luma calendar, a Discord guild), and `event_id` is a
* string so it round-trips through YAML losslessly — a large numeric id stays
* exactly as the platform reported it.
*/
export interface PublishedEventRef {
/** The platform's community identifier the event was created in. */
group: string;

/** The platform's event id, as a string. */
event_id: string;

/** Public URL of the event, when the platform reports one. */
event_url?: string;
}

/**
* Event frontmatter shape — the YAML at the top of an event markdown file.
*
Expand Down Expand Up @@ -47,6 +67,18 @@ export interface EventFrontmatter {
/** Numeric Meetup event ID, or a placeholder before creation. */
event_id?: string | number;

/**
* Every group this event has been created in, including the one mirrored by
* the `event_url` / `event_id` scalars above. Written back by
* `@coopkit/meetup` when one event is cross-posted to several groups, and
* read as the per-group idempotency record: a group listed here is never
* created again.
*
* Pure bookkeeping — it describes where the event was published, not what
* the event is, so it is deliberately absent from `NormalizedEvent`.
*/
meetup_events?: PublishedEventRef[];

/** Public registration URL (used by social media announcements). */
registration_url?: string;
}
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export type { EventFrontmatter } from "./frontmatter.js";
export type { EventFrontmatter, PublishedEventRef } from "./frontmatter.js";
export type { NormalizedEvent, NormalizedSpeaker } from "./normalized-event.js";
export { frontmatterToNormalizedEvent } from "./from-frontmatter.js";
91 changes: 85 additions & 6 deletions packages/meetup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,10 @@ bunx coopkit-meetup create-from-json --groups all --dry-run event.json
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:
Both `create` and `create-from-json` take `--groups`. Without it 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
{
Expand All @@ -164,8 +165,75 @@ original single-event shape — so existing setups are unaffected. With
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.
stay. For `create-from-json` that makes a blind retry dangerous — it keeps no
record, so it would duplicate the groups that worked unless you name the failed
groups explicitly. The file-per-event path records what it created and is safe
to re-run as-is:

#### File-per-event (`create`)

```bash
bunx coopkit-meetup create --groups all events/2026-04-29-My-Event.md
bunx coopkit-meetup create --groups "cpp-serbia,CPPTORONTO" events/2026-04-29-My-Event.md
```

`create` writes every group it created into the event file, so re-running it —
or retrying after a partial failure — creates only what is missing:

```yaml
# before
---
title: Refactoring C++ Today
date: 2026-08-22T16:00:00Z
duration: PT1H30M
venues:
- online
---
```

```yaml
# after `create --groups all`, with groupUrlname: cpp-serbia
---
title: Refactoring C++ Today
date: 2026-08-22T16:00:00Z
duration: PT1H30M
venues:
- online
event_url: 'https://www.meetup.com/cpp-serbia/events/313413133/'
event_id: '313413133'
meetup_events:
- group: cpp-serbia
event_id: '313413133'
event_url: 'https://www.meetup.com/cpp-serbia/events/313413133/'
- group: chicago-c-cpp-users-group
event_id: '314900001'
event_url: 'https://www.meetup.com/chicago-c-cpp-users-group/events/314900001/'
---
```

The `meetup_events` contract:

- One row per group the event exists in: `group`, `event_id` (a **string**, so a
large id round-trips through YAML byte-for-byte), and `event_url` when the API
reported one.
- It is the **complete** record and includes the primary group. `event_url` /
`event_id` stay as a mirror of the primary group's row, for adopter sites and
workflows that read them. A secondary group's id is never written to those
scalars: that would make a later plain `create` skip the file and so never
create the primary event at all, and would point a site's "Register on Meetup"
link at the wrong group.
- A group listed here is never created again. It is reported as
`{"ok": true, "status": "skipped", "reason": "…"}` in `--output` — `reason`
appears only in the per-group shape.
- **No migration needed** for files written before `meetup_events` existed: the
group is read out of the legacy `event_url`, which already names it.
- The file is written after **each** group, not once at the end, so a failure
halfway through still leaves the groups that succeeded recorded on disk.
- A malformed hand-edited row (no `group`, a non-numeric `event_id`) is an
error, not a warning. Ignoring a typo would silently create a duplicate draft
— the exact failure the record exists to prevent.
- When every requested group is already recorded, nothing is written at all, so
"the file did not change" keeps meaning "no work was done".

> **Why not `proNetworkEvents`?** `CreateEventInput` has a `proNetworkEvents`
> input that propagates one event across a network via a saved `filterId`. That
Expand Down Expand Up @@ -243,9 +311,14 @@ bunx coopkit-meetup create events/2026-04-29-My-Event.md

# override the configured defaultHosts for one run
bunx coopkit-meetup create --host "Rob Douglas" events/2026-04-29-My-Event.md

# create the same event across a Pro network
bunx coopkit-meetup create --groups all events/2026-04-29-My-Event.md
```

Idempotent. Writes `event_url` + `event_id` back into the file's frontmatter on success.
Idempotent. Writes `event_url` + `event_id` back into the file's frontmatter on
success, and with `--groups` a `meetup_events` list that makes the idempotency
per-group — see [Network events](#network-events-one-session-several-groups).

### From a JSON input (manual / form-driven)

Expand Down Expand Up @@ -338,6 +411,12 @@ jobs:
secrets: inherit
```

> The workflow does **not** pass `--groups` yet. It decides whether the run did
> any work by grepping `event_url` before and after, and a `--groups` run whose
> primary group is already recorded changes only `meetup_events` — that
> write-back would be discarded. Wiring `--groups` in needs that check replaced
> with `git diff --quiet` first.

### `_meetup-event-manual.yml` — manually-triggered with a form

For repos whose events don't live in structured files. A maintainer opens the Actions tab, fills in title + date + venue + …, hits Run. No event extractor needed.
Expand Down
144 changes: 144 additions & 0 deletions packages/meetup/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,22 @@ const CONFIG = JSON.stringify({
meetup: { groupUrlname: "cpp-serbia", venues: { online: 42 } },
});

const NETWORK_CONFIG = JSON.stringify({
meetup: { groupUrlname: "cpp-serbia", venues: { online: 42 }, groups: ["CPPTORONTO"] },
});

const EVENT_MARKDOWN = [
"---",
"title: Test Event",
"date: 2026-05-09T16:00:00.000Z",
"duration: PT1H",
"venues:",
" - online",
"---",
"",
"Body.",
].join("\n");

const EVENT = JSON.stringify({
id: "2026-05-09-test",
title: "Test Event",
Expand Down Expand Up @@ -155,3 +171,131 @@ describe("exit codes", () => {
}
});
});

describe("create --groups (dry-run)", () => {
it("reports every group in the config and leaves the file untouched", async () => {
const dir = scratch();
try {
const config = join(dir, "coopkit.config.json");
const eventFile = join(dir, "2026-05-09-test.md");
const out = join(dir, "result.json");
writeFileSync(config, NETWORK_CONFIG);
writeFileSync(eventFile, EVENT_MARKDOWN);

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

expect(code).toBe(0);
const result = JSON.parse(readFileSync(out, "utf8"));
expect(result.status).toBe("dry-run");
expect(result.groups.map((g: { groupUrlname: string }) => g.groupUrlname)).toEqual([
"cpp-serbia",
"CPPTORONTO",
]);
expect(readFileSync(eventFile, "utf8")).toBe(EVENT_MARKDOWN);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("keeps the single-group --output shape without --groups", async () => {
const dir = scratch();
try {
const config = join(dir, "coopkit.config.json");
const eventFile = join(dir, "2026-05-09-test.md");
const out = join(dir, "result.json");
writeFileSync(config, NETWORK_CONFIG);
writeFileSync(eventFile, EVENT_MARKDOWN);

const { code } = await run([
"create",
"--config",
config,
"--dry-run",
"--output",
out,
eventFile,
]);

expect(code).toBe(0);
const result = JSON.parse(readFileSync(out, "utf8"));
expect(result).toEqual({ status: "dry-run" });
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("exits 1 and reports partial when a group fails", async () => {
const dir = scratch();
try {
const config = join(dir, "coopkit.config.json");
const eventFile = join(dir, "2026-05-09-test.md");
const out = join(dir, "result.json");
// No "online" venue: every group fails to build its payload.
writeFileSync(
config,
JSON.stringify({
meetup: { groupUrlname: "cpp-serbia", venues: { other: 1 }, groups: ["CPPTORONTO"] },
})
);
writeFileSync(eventFile, EVENT_MARKDOWN);

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

expect(code).toBe(1);
const result = JSON.parse(readFileSync(out, "utf8"));
expect(result.status).toBe("partial");
expect(result.groups.every((g: { ok: boolean }) => !g.ok)).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it('treats --groups "," as no --groups at all, not as every group', async () => {
const dir = scratch();
try {
const config = join(dir, "coopkit.config.json");
const eventFile = join(dir, "2026-05-09-test.md");
const out = join(dir, "result.json");
writeFileSync(config, NETWORK_CONFIG);
writeFileSync(eventFile, EVENT_MARKDOWN);

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

expect(code).toBe(0);
// An empty parsed list used to be truthy, which silently meant "all".
expect(JSON.parse(readFileSync(out, "utf8"))).toEqual({ status: "dry-run" });
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
Loading
Loading