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
11 changes: 0 additions & 11 deletions .github/workflows/cli.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,12 @@ on:
branches: ["main"]
paths:
- "cli/**"
- "app/src/lib/public-api/**"
- "app/package.json"
- "app/bun.lock"
- ".github/workflows/cli.yaml"
- ".github/scripts/bun-install.sh"
pull_request:
types: [opened, synchronize, reopened]
paths:
- "cli/**"
- "app/src/lib/public-api/**"
- "app/package.json"
- "app/bun.lock"
- ".github/workflows/cli.yaml"
- ".github/scripts/bun-install.sh"

Expand All @@ -38,11 +32,6 @@ jobs:
with:
bun-version: 1.3.14

# The contract test compares the CLI's schemas with the app's.
- name: 📥 Download app deps
working-directory: app
run: bash "$GITHUB_WORKSPACE/.github/scripts/bun-install.sh"

- name: 📥 Download CLI deps
working-directory: cli
run: bash "$GITHUB_WORKSPACE/.github/scripts/bun-install.sh"
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/core.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ on:
branches: ["main"]
paths:
- "core/**"
# tests/contract.test.ts holds the CLI's copy of the contract to core's.
- "cli/src/**"
- "docs/event-formats.md"
- "infra/scripts/site-sync.ts"
- "infra/scripts/login-role.ts"
Expand All @@ -20,6 +22,8 @@ on:
types: [opened, synchronize, reopened]
paths:
- "core/**"
# tests/contract.test.ts holds the CLI's copy of the contract to core's.
- "cli/src/**"
- "docs/event-formats.md"
- "infra/scripts/site-sync.ts"
- "infra/scripts/login-role.ts"
Expand Down Expand Up @@ -81,6 +85,11 @@ jobs:
working-directory: core
run: bash "$GITHUB_WORKSPACE/.github/scripts/bun-install.sh"

# tests/contract.test.ts reads the CLI's copy of the contract.
- name: 📥 Download CLI deps
working-directory: cli
run: bash "$GITHUB_WORKSPACE/.github/scripts/bun-install.sh"

- name: 💄 Format check
working-directory: core
run: bun run check
Expand Down
19 changes: 18 additions & 1 deletion cli/src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ export function createStyler(color: boolean) {
color ? styleText(style, text, { validateStream: false }) : text;
}

/**
* "shared · by Mastra" for an evening we share, as the site marks it, or
* "shared" when who organizes it isn't said.
*/
function sharedBy(event: EventSummary): string | null {
if (event.curation !== "shared") return null;
return event.organizer === null
? "shared"
: `shared · by ${event.organizer.name}`;
}

function formatWhere(event: EventSummary): string | null {
return event.venue?.name ?? event.venue?.address ?? null;
}
Expand Down Expand Up @@ -48,8 +59,9 @@ export function formatEventList(
? ""
: ` ${style("yellow", `[${event.status}]`)}`;
const where = formatWhere(event);
const shared = sharedBy(event);
return [
`${style("bold", event.name)}${status}`,
`${style("bold", event.name)}${status}${shared ? ` ${style("dim", shared)}` : ""}`,
` ${withTimeZone(event)}${where ? ` · ${where}` : ""}`,
event.rsvpUrl && event.status !== "past"
? ` ${style("dim", "rsvp")} ${event.rsvpUrl}`
Expand All @@ -65,9 +77,11 @@ export function formatEventList(

export function formatEvent(event: Event, color: boolean): string {
const style = createStyler(color);
const shared = sharedBy(event);
const lines = [
style("bold", event.name),
event.tagline,
...(shared === null ? [] : [style("dim", shared)]),
"",
`${style("dim", "when ")} ${withTimeZone(event)}`,
];
Expand All @@ -81,6 +95,9 @@ export function formatEvent(event: Event, color: boolean): string {
lines.push(`${style("dim", "video")} ${event.recordingUrl}`);
}
lines.push(`${style("dim", "page ")} ${event.url}`);
if (event.organizer?.url) {
lines.push(`${style("dim", "by ")} ${event.organizer.url}`);
}
for (const talk of event.talks) {
lines.push("", style("bold", talk.title));
const speakers = talk.speakers
Expand Down
8 changes: 7 additions & 1 deletion cli/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { z } from "zod";

/**
* The public allthings contract, as served by https://allthings.dev/mcp.
* Mirrors app/src/lib/public-api/schemas.ts; a contract test keeps them equal.
* Mirrors core's (core/src/contract.ts), the one source of it;
* core/tests/contract.test.ts holds the two to the same JSON Schema and the
* same verdicts.
*/

export const eventStatusSchema = z.enum(["upcoming", "live", "past"]);
Expand All @@ -28,6 +30,10 @@ export const eventSummarySchema = z.object({
rsvpUrl: z.httpUrl().nullable(),
recordingUrl: z.httpUrl().nullable(),
isHackathon: z.boolean(),
curation: z.enum(["ours", "shared"]),
organizer: z
.object({ name: z.string(), url: z.httpUrl().nullable() })
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.nullable(),
});

export const talkSpeakerSchema = z.object({
Expand Down
27 changes: 27 additions & 0 deletions cli/tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,33 @@ describe("allthings", () => {
expect(stdout).toContain("rsvp https://lu.ma/event/evt-1");
});

test("marks an evening we share with who organizes it", async () => {
const shared = {
...summary,
curation: "shared" as const,
organizer: { name: "Mastra", url: "https://mastra.ai/" },
};
const client = fakeClient({
listEvents: async () => [shared],
getEvent: async () => ({ ...event, ...shared }),
});
expect((await exec(["events"], { client })).stdout).toContain(
"Effect San Francisco [upcoming] shared · by Mastra",
);
const { stdout } = await exec(["event", summary.slug], { client });
expect(stdout).toContain("shared · by Mastra");
expect(stdout).toContain("by https://mastra.ai/");
// Ours says nothing of it.
expect((await exec(["events"])).stdout).not.toContain("shared");
// Shared without a named organizer still says so.
const unnamed = fakeClient({
listEvents: async () => [{ ...shared, organizer: null }],
});
expect((await exec(["events"], { client: unnamed })).stdout).toContain(
"Effect San Francisco [upcoming] shared\n",
);
});

test("--json prints exactly the public contract with no color", async () => {
const { stdout } = await exec(["event", summary.slug, "--json"], {
isTTY: true,
Expand Down
31 changes: 0 additions & 31 deletions cli/tests/contract.test.ts

This file was deleted.

2 changes: 2 additions & 0 deletions cli/tests/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export const summary: EventSummary = {
rsvpUrl: "https://lu.ma/event/evt-1",
recordingUrl: null,
isHackathon: false,
curation: "ours",
organizer: null,
};

export const event: Event = {
Expand Down
22 changes: 15 additions & 7 deletions core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ statement from it.
`latestAppearanceFirst`). /people is our evenings' at any time; a
person's page, every evening's; /about counts ours once over; the sitemap
lists everyone at any; the speakers list (`list_speakers`,
`/api/v1/speakers`) counts any evening's talks once `ended`.
`/api/v1/speakers`) counts our evenings' talks once `ended`.

Each surface still reads in one statement, since from a Worker every round
trip costs more than the query, and builds it from these fragments. What a
Expand All @@ -64,11 +64,14 @@ seeded database at one instant: the same evenings in the same order from
`list_events`, `/api/v1/events`, the feed, /events and home; the same
lineup from `get_event`, `/api/v1/events/:id` and the event page, by every
link; the same people and talks from `list_speakers`, `/api/v1/speakers`,
/people, people's pages, /about and the sitemap. Where they disagree today
a test says so (`test.failing`), with what settles it:
/people, people's pages, /about and the sitemap; and whose each evening is,
from `list_events` and /events.

- `list_speakers` and `/api/v1/speakers` list the speakers of evenings we
only share.
The public contract is `src/contract.ts`, the one source of it. The CLI's
zod copy (`cli/src/schemas.ts`) is held to it by `tests/contract.test.ts`,
which the core workflow runs whenever `cli/src` changes. The old app's
schemas and answers are legacy: where tests still compare the Worker with
the app, it is on the fields the app has.

## Luma sync

Expand Down Expand Up @@ -432,8 +435,13 @@ What follows from it:
Home's hero is always our next evening, and its photos are of ours.
- Its page says who organizes it, linked to their site, and has no "your
hosts". Its structured data names the organizer, not us.
- /about's numbers count our evenings alone, and the people page lists who
was on stage at ours; people from a shared evening are on its page.
- /about's numbers count our evenings alone, and the people page and the
speakers list (`list_speakers`, `/api/v1/speakers`) list who was on
stage at ours; people from a shared evening are on its page.
- The MCP tools say whose an evening is: `curation` (`ours` or `shared`)
and `organizer` (its name and site; null for ours), and the CLI marks a
shared one "shared · by Mastra". The v1 API publishes `curation` and
`organizedBy`.
- Completeness asks a shared evening for no organizers, MC or topic.
- Promotion drafts recommend it, by its organizer, and say why; it gets no
Luma description or Meetup listing of ours.
Expand Down
22 changes: 18 additions & 4 deletions core/src/contract.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Schema } from "effect";

/**
* The public allthings contract: the shapes served by the MCP server and the
* public API, and consumed by the CLI. These Effect schemas will replace the
* zod ones in app/src/lib/public-api/schemas.ts; until then a contract test
* holds them to the same shapes, the same validation and today's live data.
* The public allthings contract: the shapes served by the MCP server, and
* consumed by the CLI and agents. This is the one source of it: the CLI's
* zod copy (cli/src/schemas.ts) is held to it by tests/contract.test.ts,
* the same JSON Schema and the same verdicts. The app's zod schemas
* (app/src/lib/public-api/schemas.ts) are legacy and no longer a reference.
*/

// zod's `domain` regex, so hosts are judged exactly as `z.httpUrl()` judges them.
Expand Down Expand Up @@ -65,6 +66,12 @@ export const PersonLinks = Schema.Struct({
linkedin: Schema.NullOr(HttpUrl),
});

/** Whose evening it is. */
export const Curation = Schema.Literals(["ours", "shared"]).annotate({
description:
"Whose evening it is: one of allthings' own (ours), or someone else's evening we share with the community (shared).",
});

export const EventSummary = Schema.Struct({
slug: Schema.String.annotate({
description: "Stable identifier; pass it to get_event.",
Expand Down Expand Up @@ -93,6 +100,13 @@ export const EventSummary = Schema.Struct({
}),
recordingUrl: Schema.NullOr(HttpUrl),
isHackathon: Schema.Boolean,
curation: Curation,
organizer: Schema.NullOr(
Schema.Struct({ name: Schema.String, url: Schema.NullOr(HttpUrl) }),
).annotate({
description:
"Who organizes an evening we share, and their site; null for ours.",
}),
});

export const TalkSpeaker = Schema.Struct({
Expand Down
4 changes: 2 additions & 2 deletions core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ export class EventNotFound extends Schema.TaggedError<EventNotFound>()(
{ slug: Schema.String },
) {
/**
* The text clients see. The CLI maps it to its "not found" exit code, so it
* must stay identical to app/src/lib/public-api/errors.ts.
* The text clients see. The CLI maps it to its "not found" exit code
* (cli/src/client.ts); tests/contract.test.ts holds the two together.
*/
override get message(): string {
return `No published event has the slug "${this.slug}". Use list_events to find one.`;
Expand Down
10 changes: 8 additions & 2 deletions core/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ import {
} from "./catalog.ts";
import { type DataSourceError, EventNotFound } from "./errors.ts";
import * as Rows from "./rows.ts";
import { imageJson, orDataSourceError, profileJson } from "./sql.ts";
import {
curationJson,
imageJson,
orDataSourceError,
profileJson,
} from "./sql.ts";

/**
* Published events. Drafts never leave this service: they are filtered in SQL,
Expand Down Expand Up @@ -43,7 +48,8 @@ const make = Effect.gen(function* () {
e.street_address AS "streetAddress", e.short_location AS "shortLocation",
e.full_address AS "fullAddress", e.luma_event_id AS "lumaEventId",
e.recording_url AS "recordingUrl", e.is_hackathon AS "isHackathon",
${imageJson("e.preview_image")} AS "previewImage"`);
${imageJson("e.preview_image")} AS "previewImage",
${curationJson("e")} AS curation`);

const listPublished = SqlSchema.findAll({
Request: Schema.Void,
Expand Down
8 changes: 8 additions & 0 deletions core/src/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ export function toEventSummary(
rsvpUrl: rsvpUrl(event.lumaEventId),
recordingUrl: httpUrlOrNull(event.recordingUrl),
isHackathon: event.isHackathon,
curation: event.curation.kind,
organizer:
event.curation.kind === "shared"
? {
name: event.curation.organizer.name,
url: httpUrlOrNull(event.curation.organizer.websiteUrl),
}
: null,
};
}

Expand Down
Loading
Loading