diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 08399b6..c35c9b6 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -2,8 +2,8 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "aaif-meetups", "displayName": "AAIF Meetups Toolkit", - "version": "0.2.0", - "description": "Skills for running AAIF (Agentic AI Foundation) in-person and online meetups — event content writing (announcements, Luma pages, LinkedIn carousels, recaps, speaker bios/invites, day-of slides, attendee reminders) and chapter ops (spin up a new city chapter or online event series, triage community intake, clean intake data).", + "version": "0.4.0", + "description": "Skills for running AAIF (Agentic AI Foundation) in-person and online meetups — event content writing (announcements, Luma pages, LinkedIn carousels, recaps, speaker bios/invites, day-of slides, attendee reminders) and chapter ops (spin up a new city chapter or online event series, triage community intake, clean intake data, sync accepted organizers into the chapters list, and push/sync events to Luma with live RSVP stats).", "author": { "name": "AAIF", "url": "https://github.com/aaif" diff --git a/CHANGELOG.md b/CHANGELOG.md index af06caf..9696c6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,54 @@ All notable changes to the **AAIF Meetups Toolkit** plugin are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the plugin version is the `version` field in `.claude-plugin/plugin.json`. +## [0.4.0] + +### Added +- **Luma API integration** (`lib/aaif_meetups/luma.py`): stdlib client for the + Luma public API (`public-api.luma.com`, per-calendar key from `LUMA_API_KEY` + or the `luma-api-key` keychain item; Luma Plus required) with pure, unit-tested + payload builders. All live writes sit behind explicit `--create`/`--apply` + flags that the agent only runs after the user approves the printed proposal. + Every script detects whether Luma is connected: when no key is configured it + degrades gracefully — the push prints the proposal as manual-creation details, + the sync prints the desired values as a manual checklist, and the stats step + is skipped with a note — instead of erroring. +- `aaif-create-event` → `scripts/luma_push.py`: create the live Luma event page + from the tracker entry — times from DATE & TIME + IANA timezone, venue as a + manual address, capacity, description markdown (from `aaif-luma-description`), + banner PNG uploaded as the cover, hosts (manager / check-in) — then write the + event URL back into the tracker's LUMA URL field. Aborts if already pushed. +- `aaif-update-event` → `scripts/luma_sync.py`: field-by-field diff of the + tracker vs the live event; `--apply` pushes only the changed fields + (`--quiet` suppresses Luma's guest notifications) and re-verifies. + Cancellation deliberately not automated. +- `aaif-event-status` → `scripts/luma_stats.py`: read-only guest counts + (going / pending / waitlist / invited / declined / checked-in) and + registration state for pushed events; feeds day-of slides and recap numbers. + Luma data is never written back into the Intake Ops sheet. + +## [0.3.0] + +### Added +- `aaif-sync-chapters` skill — sync organizer decisions from the **Intake Ops** + sheet into the **Chapters List**: merge `Accepted` / `Existing (from MLOps)` + organizers into each city row's Organizers column and append rows for net-new + cities (with their Luma link). Report-and-propose by default, one atomic + `batchUpdate` on approval, idempotent, with unresolved-city and near-miss-city + guardrails. Unit tests for the pure merge/slug/near-miss logic. + +### Fixed +- `gws` JSON parsing (`gws_json` in the sync, create-chapter, and + create-online-series engines) now splits output on `\n` only — Python's + `splitlines()` also splits on U+2028 (line separator) *inside* string values, + which corrupted the JSON when rejoined (hit by a real intake row). +- `aaif-clean-data` treats **any** `Other…` city placeholder as unresolved — the + form emits both `Other` and `Other (PLEASE TELL US WHERE IN NEXT QUESTION)`, + and the exact-string match left long-variant rows unflagged (24 on live data) + and wrongly painted their `City (Existing)` green. The green rule now checks + the `Other` prefix; retired formulas are tracked in `LEGACY_COLOR_FORMULAS` so + `install-colors` replaces old rules instead of stacking duplicates. + ## [0.2.0] ### Added diff --git a/lib/aaif_meetups/luma.py b/lib/aaif_meetups/luma.py new file mode 100644 index 0000000..e51b887 --- /dev/null +++ b/lib/aaif_meetups/luma.py @@ -0,0 +1,334 @@ +"""Minimal Luma public-API client + pure payload builders. + +API: https://docs.luma.com/reference (base https://public-api.luma.com, +auth header `x-luma-api-key`, Luma Plus required). Keys are scoped to ONE +calendar — events are created on the calendar the key belongs to. + +LIVE-WRITE CONTRACT: nothing in this module writes on import or by accident; +`create_event` / `update_event` / `add_host` / `upload_image` hit the live API +and must only be reached behind an explicit user-approved --create / --apply +flag in the calling script. + +The payload builders (`event_times`, `event_payload`, `diff_payload`, +`slug_of_url`) are pure and unit-tested without network. +""" +import datetime as dt +import json +import os +import re +import subprocess +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from zoneinfo import ZoneInfo + +from aaif_meetups import tracker + +BASE = "https://public-api.luma.com" +KEYCHAIN_SERVICE = "luma-api-key" +_TRANSIENT_HTTP = (429, 500, 502, 503, 504) +_IMAGE_MIME = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".webp": "image/webp", ".gif": "image/gif"} + + +class LumaError(RuntimeError): + status = None # HTTP status when the API answered; None for network failures + + +class NotAnEventUrl(LumaError): + """The URL/slug resolves to a non-event entity (e.g. a chapter calendar).""" + + +# --------------------------------------------------------------------------- +# Auth + transport +# --------------------------------------------------------------------------- +def api_key(): + """LUMA_API_KEY env var, else the macOS keychain item `luma-api-key`.""" + k = os.environ.get("LUMA_API_KEY", "").strip() + if k: + return k + try: + r = subprocess.run(["security", "find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"], + capture_output=True, text=True) + except OSError: # no `security` CLI (non-macOS) — no keychain to read + r = None + if r is not None and r.returncode == 0 and r.stdout.strip(): + return r.stdout.strip() + hint = "" + if r is not None and r.returncode != 0: + err = (r.stderr or "").strip() + # item-not-found is the normal "no key stored" case; anything else + # (locked keychain, denied ACL) must not masquerade as "no key" + if err and "could not be found" not in err: + hint = "\nKeychain lookup failed (not just missing): %s" % err.splitlines()[-1] + raise LumaError( + "No Luma API key found. Create one in the calendar's settings (Luma Plus, " + "keys are per-calendar), then either export LUMA_API_KEY or store it with:\n" + " security add-generic-password -s %s -a aaif -w THE_KEY%s" + % (KEYCHAIN_SERVICE, hint)) + + +def available(): + """True if a Luma API key is configured (env or keychain). Skills use this + to decide: connected -> propose/do the Luma step; not connected -> skip it + and hand the user the manual instructions instead. Never raises.""" + try: + api_key() + return True + except LumaError: + return False + + +def call(method, path, params=None, body=None, retries=4): + """One API call. GETs retry transient HTTP/network errors with backoff; + writes (POST) are never blind-retried — a timed-out create/update may have + gone through on the server, so a retry could duplicate the event or + double-send guest notifications — except on 429, which Luma sends before + doing any work.""" + url = BASE + path + ("?" + urllib.parse.urlencode(params) if params else "") + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"x-luma-api-key": api_key(), "accept": "application/json"} + if data is not None: + headers["content-type"] = "application/json" + write_hint = ("" if method == "GET" else + " — the write may still have gone through; check Luma before retrying") + retries = max(1, retries) + for i in range(retries): + req = urllib.request.Request(url, data=data, method=method, headers=headers) + try: + with urllib.request.urlopen(req, timeout=30) as r: + return json.loads(r.read().decode("utf-8") or "{}") + except urllib.error.HTTPError as e: + detail = e.read().decode("utf-8", errors="replace")[:300] + retryable = e.code == 429 or (method == "GET" and e.code in _TRANSIENT_HTTP) + if retryable and i < retries - 1: + print("luma: HTTP %d on %s %s — retry %d/%d in %ds" + % (e.code, method, path, i + 1, retries - 1, 2 * (i + 1)), + file=sys.stderr) + time.sleep(2 * (i + 1)) + continue + err = LumaError("%s %s -> HTTP %d: %s%s" + % (method, path, e.code, detail, + write_hint if e.code in _TRANSIENT_HTTP and e.code != 429 + else "")) + err.status = e.code + raise err + except (urllib.error.URLError, TimeoutError) as e: + if method == "GET" and i < retries - 1: + print("luma: %s %s unreachable — retry %d/%d in %ds" + % (method, path, i + 1, retries - 1, 2 * (i + 1)), file=sys.stderr) + time.sleep(2 * (i + 1)) + continue + raise LumaError("Luma unreachable (%s %s): %s%s" + % (method, path, e, write_hint)) + + +# --------------------------------------------------------------------------- +# Endpoints (thin) +# --------------------------------------------------------------------------- +def get_self(): + return call("GET", "/v1/users/get-self") + + +def get_calendar(): + return call("GET", "/v1/calendars/get") + + +def get_event(event_id): + return call("GET", "/v1/events/get", params={"event_id": event_id}) + + +def lookup_slug(slug): + return call("GET", "/v1/entities/lookup", params={"slug": slug}) + + +def create_event(payload): + return call("POST", "/v1/events/create", body=payload) + + +def update_event(payload): + return call("POST", "/v1/events/update", body=payload) + + +def add_host(event_id, email, name=None, access_level=None): + body = {"event_id": event_id, "email": email} + if name: + body["name"] = name + if access_level: + body["access_level"] = access_level # "manager" (default) or "check-in" + return call("POST", "/v1/events/hosts/add", body=body) + + +def upload_image(path): + """Upload a local image to the Luma CDN; returns the file_url for cover_url.""" + ext = os.path.splitext(path)[1].lower() + mime = _IMAGE_MIME.get(ext) + if not mime: + raise LumaError("unsupported cover image type %r (use %s)" + % (ext, "/".join(sorted(_IMAGE_MIME)))) + res = call("POST", "/v1/images/create-upload-url", body={"content_type": mime}) + with open(path, "rb") as f: + data = f.read() + # The upload URL is pre-signed — no API key header, plain PUT of the bytes. + req = urllib.request.Request(res["upload_url"], data=data, method="PUT", + headers={"content-type": mime}) + try: + with urllib.request.urlopen(req, timeout=120) as r: + if r.status not in (200, 201, 204): + raise LumaError("image upload failed: HTTP %d" % r.status) + except (urllib.error.URLError, TimeoutError) as e: # HTTPError included + raise LumaError("image upload failed: %s" % e) + return res["file_url"] + + +def resolve_event_id(url_or_slug): + """Resolve a luma.com event URL (or bare slug) to an evt- id via entity lookup.""" + slug = slug_of_url(url_or_slug) + ent = lookup_slug(slug).get("entity") or {} + if ent.get("type") != "event": + raise NotAnEventUrl("slug %r is a %s, not an event (chapter calendar links " + "can't be synced — pass the event page URL)" + % (slug, ent.get("type") or "unknown entity")) + ev = ent.get("event") or {} + event_id = ev.get("id") or ev.get("api_id") + if not event_id: + raise LumaError("entity lookup for %r returned no event id (keys: %s)" + % (slug, ", ".join(sorted(ev)))) + return event_id + + +# --------------------------------------------------------------------------- +# Pure payload builders (no network) +# --------------------------------------------------------------------------- +_TIME_RE = re.compile(r"\b(\d{1,2}):(\d{2})(?:\s*([ap])\.?m\.?)?", re.I) +_URL_RE = re.compile(r"https?://\S+|(?:www\.|lu\.ma/|luma\.com/)\S+", re.I) + + +def slug_of_url(url_or_slug): + """Last path segment of a luma.com / lu.ma URL; bare slugs pass through.""" + s = url_or_slug.strip().rstrip("/") + s = re.sub(r"^https?://", "", s) + return s.rsplit("/", 1)[-1].split("?")[0] + + +def _hour24(h, m, ampm): + """(hour, minute) from a _TIME_RE match; honors an AM/PM marker if present.""" + h = int(h) + if ampm: + if ampm.lower() == "p" and h != 12: + h += 12 + elif ampm.lower() == "a" and h == 12: + h = 0 + return h, int(m) + + +def event_times(date_text, tz_name, duration_hours=3.0): + """DATE & TIME cell -> (start, end) timezone-aware datetimes. + + The date comes from tracker.parse_event_date (requires a 4-digit year); the + start time is the first HH:MM in the text — required, so a placeholder cell + can't silently become a midnight event. An AM/PM marker is honored ("6:00 PM" + is 18:00, not 06:00). A second HH:MM ("17:30 — 20:30") is the end time; + otherwise end = start + duration_hours ("18:00 — late"). + """ + d = tracker.parse_event_date(date_text) + tz = ZoneInfo(tz_name) + times = _TIME_RE.findall(date_text) + if not times: + raise ValueError("no HH:MM start time in DATE & TIME: %r" % date_text) + h, m = _hour24(*times[0]) + start = dt.datetime(d.year, d.month, d.day, h, m, tzinfo=tz) + if len(times) > 1: + h, m = _hour24(*times[1]) + end = dt.datetime(d.year, d.month, d.day, h, m, tzinfo=tz) + if end <= start: # "21:00 — 00:30" crosses midnight + end += dt.timedelta(days=1) + else: + end = start + dt.timedelta(hours=duration_hours) + return start, end + + +def iso_utc(t): + return t.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z") + + +def _capacity_of(text): + m = re.search(r"\d+", text or "") + return int(m.group(0)) if m else None + + +def event_payload(view, tz_name, duration_hours=3.0, description_md=None, + cover_url=None, slug=None): + """Build the events/create payload from a tracker event view (read_event/ + view_event dict). In-person trackers map VENUE + LOCATION / CITY to a manual + address; series trackers map STREAM / JOIN LINK to meeting_url. Placeholder + text flows through as-is — the proposal is reviewed by a human before create. + + Consumes only view["details"], reading these labels: EVENT TITLE, + DATE & TIME, VENUE, LOCATION / CITY, STREAM / JOIN LINK, CAPACITY / RSVPS. + """ + det = view["details"] + name = (det.get("EVENT TITLE") or "").strip() + if not name: + raise ValueError("tracker event has no EVENT TITLE") + start, end = event_times(det.get("DATE & TIME", ""), tz_name, duration_hours) + payload = {"name": name, "start_at": iso_utc(start), "end_at": iso_utc(end), + "timezone": tz_name, "visibility": "public"} + venue = (det.get("VENUE") or "").strip() + city = (det.get("LOCATION / CITY") or "").strip() + if venue or city: + payload["geo_address_json"] = {"type": "manual", + "address": ", ".join(x for x in (venue, city) if x)} + join = (det.get("STREAM / JOIN LINK") or "").strip() + m = _URL_RE.search(join) + if m: + u = m.group(0) + payload["meeting_url"] = u if u.startswith("http") else "https://" + u + cap = _capacity_of(det.get("CAPACITY / RSVPS")) + if cap: + payload["max_capacity"] = cap + if description_md: + payload["description_md"] = description_md + if cover_url: + payload["cover_url"] = cover_url + if slug: + payload["slug"] = slug + return payload + + +def _norm(key, value): + """Normalize one field for live-vs-desired comparison.""" + if value is None: + return None + if key in ("start_at", "end_at"): + # compare as instants at second granularity; live returns fractional + # seconds ("...:00.673Z") that would make every diff look dirty + s = str(value).replace("Z", "+00:00") + try: + return dt.datetime.fromisoformat(s).astimezone(dt.timezone.utc).replace(microsecond=0) + except ValueError: + return str(value) + if isinstance(value, str): + return value.strip() + return value + + +def diff_payload(live, desired): + """Fields of `desired` that differ from the `live` event, as + {field: (live_value, desired_value)}. geo_address_json compares only the + keys `desired` sets (live may carry extra provider fields); description_md + is treated as changed whenever provided and textually different (Luma's + Spark round-trip isn't byte-stable, so only pass it when pushing new copy). + """ + changes = {} + for key, want in desired.items(): + have = live.get(key) + if key == "geo_address_json" and isinstance(want, dict) and isinstance(have, dict): + if all(_norm(None, have.get(k)) == _norm(None, v) for k, v in want.items()): + continue + elif _norm(key, have) == _norm(key, want): + continue + changes[key] = (have, want) + return changes diff --git a/lib/aaif_meetups/tests/test_luma.py b/lib/aaif_meetups/tests/test_luma.py new file mode 100644 index 0000000..6ea5ab9 --- /dev/null +++ b/lib/aaif_meetups/tests/test_luma.py @@ -0,0 +1,240 @@ +import datetime as dt +import io +import unittest +import urllib.error +from unittest import mock + +from aaif_meetups import luma + + +def view(**details): + return {"title": details.get("EVENT TITLE", ""), "details": details, + "phases": [], "date": None} + + +IRL = {"EVENT TITLE": "Eval Night · Builder Series", + "DATE & TIME": "Wed · August 12, 2026 · 18:00 — late", + "VENUE": "Github HQ", "LOCATION / CITY": "San Francisco", + "CAPACITY / RSVPS": "120 RSVPs", "LUMA URL": "luma.com/aaif-sanfrancisco"} + + +class TestAvailable(unittest.TestCase): + def test_env_key_means_connected(self): + with mock.patch.dict("os.environ", {"LUMA_API_KEY": "secret"}): + self.assertTrue(luma.available()) + + def test_no_key_anywhere_means_not_connected_not_raising(self): + with mock.patch.dict("os.environ", {"LUMA_API_KEY": ""}), \ + mock.patch("subprocess.run", + return_value=mock.Mock(returncode=44, stdout="", stderr=( + "security: SecKeychainSearchCopyNext: The specified item " + "could not be found in the keychain."))): + self.assertFalse(luma.available()) + + def test_keychain_failure_is_distinguished_from_no_key(self): + # a locked keychain / denied ACL must not masquerade as "no key stored" + with mock.patch.dict("os.environ", {"LUMA_API_KEY": ""}), \ + mock.patch("subprocess.run", + return_value=mock.Mock(returncode=1, stdout="", + stderr="User interaction is not allowed.")): + with self.assertRaises(luma.LumaError) as cm: + luma.api_key() + self.assertIn("Keychain lookup failed", str(cm.exception)) + self.assertFalse(luma.available()) # still degrades, never raises + + def test_missing_security_binary_means_not_connected_not_raising(self): + # non-macOS: the `security` CLI doesn't exist at all + with mock.patch.dict("os.environ", {"LUMA_API_KEY": ""}), \ + mock.patch("subprocess.run", side_effect=FileNotFoundError("security")): + self.assertFalse(luma.available()) + with self.assertRaises(luma.LumaError): + luma.api_key() + + +class TestCall(unittest.TestCase): + """The retry contract: GETs retry transient errors, writes never blind-retry + (a timed-out create may have gone through) except 429.""" + + def setUp(self): + for p in (mock.patch.dict("os.environ", {"LUMA_API_KEY": "k"}), + mock.patch("time.sleep")): + p.start() + self.addCleanup(p.stop) + + def _http_error(self, code): + return urllib.error.HTTPError("https://x", code, "err", None, io.BytesIO(b"boom")) + + def _ok(self): + r = mock.MagicMock() + r.__enter__.return_value = r + r.read.return_value = b'{"ok": true}' + return r + + def test_get_retries_transient_then_succeeds(self): + with mock.patch("urllib.request.urlopen", + side_effect=[self._http_error(503), self._ok()]) as uo: + self.assertEqual(luma.call("GET", "/v1/x"), {"ok": True}) + self.assertEqual(uo.call_count, 2) + + def test_post_transient_error_is_not_retried(self): + with mock.patch("urllib.request.urlopen", + side_effect=self._http_error(503)) as uo: + with self.assertRaises(luma.LumaError) as cm: + luma.call("POST", "/v1/events/create", body={}) + self.assertEqual(uo.call_count, 1) + self.assertIn("may still have gone through", str(cm.exception)) + self.assertEqual(cm.exception.status, 503) + + def test_post_429_is_retried(self): + with mock.patch("urllib.request.urlopen", + side_effect=[self._http_error(429), self._ok()]) as uo: + self.assertEqual(luma.call("POST", "/v1/events/create", body={}), {"ok": True}) + self.assertEqual(uo.call_count, 2) + + def test_get_4xx_fails_immediately_with_status(self): + with mock.patch("urllib.request.urlopen", + side_effect=self._http_error(404)) as uo: + with self.assertRaises(luma.LumaError) as cm: + luma.call("GET", "/v1/x") + self.assertEqual(uo.call_count, 1) + self.assertEqual(cm.exception.status, 404) + + def test_get_network_error_retries_then_raises_without_status(self): + with mock.patch("urllib.request.urlopen", + side_effect=urllib.error.URLError("down")) as uo: + with self.assertRaises(luma.LumaError) as cm: + luma.call("GET", "/v1/x", retries=2) + self.assertEqual(uo.call_count, 2) + self.assertIsNone(cm.exception.status) + + +class TestResolveEventId(unittest.TestCase): + def test_calendar_entity_raises_not_an_event(self): + with mock.patch.object(luma, "lookup_slug", + return_value={"entity": {"type": "calendar"}}): + with self.assertRaises(luma.NotAnEventUrl): + luma.resolve_event_id("luma.com/aaif-sanfrancisco") + + def test_api_id_fallback(self): + with mock.patch.object(luma, "lookup_slug", + return_value={"entity": {"type": "event", + "event": {"api_id": "evt-1"}}}): + self.assertEqual(luma.resolve_event_id("https://luma.com/x"), "evt-1") + + def test_event_with_no_id_raises(self): + with mock.patch.object(luma, "lookup_slug", + return_value={"entity": {"type": "event", "event": {}}}): + with self.assertRaises(luma.LumaError): + luma.resolve_event_id("https://luma.com/x") + + +class TestUploadImage(unittest.TestCase): + def test_unsupported_extension_rejected_before_any_network(self): + with self.assertRaises(luma.LumaError): + luma.upload_image("banner.pdf") + + +class TestSlugOfUrl(unittest.TestCase): + def test_forms(self): + for given in ("https://luma.com/ia70fwmm", "https://lu.ma/ia70fwmm/", + "luma.com/ia70fwmm?utm=x", "ia70fwmm"): + self.assertEqual(luma.slug_of_url(given), "ia70fwmm", given) + + +class TestEventTimes(unittest.TestCase): + def test_start_plus_duration(self): + start, end = luma.event_times(IRL["DATE & TIME"], "America/Los_Angeles", 3) + self.assertEqual((start.year, start.month, start.day, start.hour, start.minute), + (2026, 8, 12, 18, 0)) + self.assertEqual(end - start, dt.timedelta(hours=3)) + + def test_explicit_end_time(self): + start, end = luma.event_times("July 8, 2026 · 17:30 — 20:30", "Europe/Berlin", 3) + self.assertEqual((start.hour, end.hour, end.minute), (17, 20, 30)) + + def test_end_past_midnight_rolls_to_next_day(self): + start, end = luma.event_times("July 8, 2026 · 21:00 — 00:30", "Europe/Berlin", 3) + self.assertEqual(end.day, start.day + 1) + + def test_12_hour_times_honor_am_pm(self): + # "6:00 PM" must be 18:00, never a silent 6 AM event + start, end = luma.event_times("July 8, 2026 · 6:00 PM — 9:30 pm", "UTC", 3) + self.assertEqual((start.hour, end.hour, end.minute), (18, 21, 30)) + + def test_noon_and_midnight_edge_cases(self): + start, _ = luma.event_times("July 8, 2026 · 12:00 PM", "UTC", 1) + self.assertEqual(start.hour, 12) + start, _ = luma.event_times("July 8, 2026 · 12:30 AM", "UTC", 1) + self.assertEqual(start.hour, 0) + + def test_missing_time_raises(self): + with self.assertRaises(ValueError): + luma.event_times("Wed · August 12, 2026 · evening", "UTC", 3) + + def test_iso_utc_converts_zone(self): + start, _ = luma.event_times(IRL["DATE & TIME"], "America/Los_Angeles", 3) + self.assertEqual(luma.iso_utc(start), "2026-08-13T01:00:00.000Z") # PDT = UTC-7 + + +class TestEventPayload(unittest.TestCase): + def test_in_person(self): + p = luma.event_payload(view(**IRL), "America/Los_Angeles", + description_md="# Agenda", slug="aaif-sf-evalnight") + self.assertEqual(p["name"], "Eval Night · Builder Series") + self.assertEqual(p["timezone"], "America/Los_Angeles") + self.assertEqual(p["geo_address_json"], + {"type": "manual", "address": "Github HQ, San Francisco"}) + self.assertEqual(p["max_capacity"], 120) + self.assertEqual(p["visibility"], "public") + self.assertEqual(p["description_md"], "# Agenda") + self.assertEqual(p["slug"], "aaif-sf-evalnight") + self.assertNotIn("meeting_url", p) + + def test_online_series(self): + p = luma.event_payload(view(**{ + "EVENT TITLE": "Reading Group: Loop Engineering", + "DATE & TIME": "July 20, 2026 · 9:00", + "STREAM / JOIN LINK": "join at lu.ma/ia70fwmm"}), "UTC", 1.0) + self.assertEqual(p["meeting_url"], "https://lu.ma/ia70fwmm") + self.assertNotIn("geo_address_json", p) + self.assertNotIn("max_capacity", p) + + def test_placeholder_capacity_and_no_title(self): + self.assertIsNone(luma._capacity_of("TBD")) + with self.assertRaises(ValueError): + luma.event_payload(view(**{"DATE & TIME": "July 8, 2026 · 18:00"}), "UTC") + + +class TestDiffPayload(unittest.TestCase): + LIVE = {"name": "Eval Night · Builder Series", + "start_at": "2026-08-13T01:00:00.673Z", + "timezone": "America/Los_Angeles", + "geo_address_json": {"type": "manual", "address": "Github HQ, San Francisco", + "description": "extra provider field"}, + "max_capacity": 120} + + def test_no_change_is_empty(self): + desired = {"name": "Eval Night · Builder Series", + "start_at": "2026-08-13T01:00:00.000Z", # same instant, different ms + "timezone": "America/Los_Angeles", + "geo_address_json": {"type": "manual", + "address": "Github HQ, San Francisco"}, + "max_capacity": 120} + self.assertEqual(luma.diff_payload(self.LIVE, desired), {}) + + def test_changes_reported_pairwise(self): + d = luma.diff_payload(self.LIVE, {"name": "Eval Night v2", "max_capacity": 150}) + self.assertEqual(d, {"name": ("Eval Night · Builder Series", "Eval Night v2"), + "max_capacity": (120, 150)}) + + def test_new_field_counts_as_change(self): + d = luma.diff_payload(self.LIVE, {"description_md": "# New copy"}) + self.assertEqual(d, {"description_md": (None, "# New copy")}) + + def test_genuine_time_change_is_reported(self): + d = luma.diff_payload(self.LIVE, {"start_at": "2026-08-13T02:00:00.000Z"}) + self.assertIn("start_at", d) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/aaif-clean-data/scripts/clean.py b/skills/aaif-clean-data/scripts/clean.py index 6c1003e..8de3b3a 100755 --- a/skills/aaif-clean-data/scripts/clean.py +++ b/skills/aaif-clean-data/scripts/clean.py @@ -80,9 +80,16 @@ def colletter(n): # 1-based -> A1 letter # used to install them. VIOLET_FORMULA = '=$A2="Existing (from MLOps)"' AMBER_FORMULA = f'=${colletter(CITY_NEW_COL)}2<>""' +# "Real city" = non-empty and not ANY "Other..." placeholder — the form has both +# "Other" and "Other (PLEASE TELL US WHERE IN NEXT QUESTION)", so match the prefix. GREEN_FORMULA = (f'=AND(${colletter(CITY_EXISTING_COL)}2<>"",' - f'${colletter(CITY_EXISTING_COL)}2<>"Other")') + f'LEFT(${colletter(CITY_EXISTING_COL)}2,5)<>"Other")') COLOR_FORMULAS = {VIOLET_FORMULA, AMBER_FORMULA, GREEN_FORMULA} +# Formulas earlier releases installed: matched as stale so a refresh REPLACES +# them instead of stacking a duplicate rule next to the old one. +LEGACY_COLOR_FORMULAS = {(f'=AND(${colletter(CITY_EXISTING_COL)}2<>"",' + f'${colletter(CITY_EXISTING_COL)}2<>"Other")')} +STALE_COLOR_FORMULAS = COLOR_FORMULAS | LEGACY_COLOR_FORMULAS # The two source-column headers we expect at G/H before (or after) labeling. CITY_SRC_HEADERS = ({"City", "City (Existing)"}, {"Resolved City", "City (New)"}) @@ -113,7 +120,7 @@ def color_rule_plan(cfs): rule so it keeps top priority — computed from the red rule's ACTUAL position (not assumed to be index 0), adjusted for the stale rules deleted above it, since deletes and adds run in one batch. Testable without touching Sheets.""" - stale = [i for i, cf in enumerate(cfs) if formula_of(cf) in COLOR_FORMULAS] + stale = [i for i, cf in enumerate(cfs) if formula_of(cf) in STALE_COLOR_FORMULAS] red = next((i for i, cf in enumerate(cfs) if _is_red(cf)), None) if red is None: base = 0 @@ -221,7 +228,9 @@ def prop(i, fn, header): if link and "linkedin.com/" not in link: flags.append({"row": rn, "who": who, "issue": f"LinkedIn not a profile URL: {row[li].strip()}"}) resolved = (row[ri].strip() if ri is not None and ri < len(row) else "") - if city.lower() == "other" and not resolved: + # The form has two placeholders: "Other" and "Other (PLEASE TELL US + # WHERE IN NEXT QUESTION)" — match the prefix, not the exact string. + if city.lower().startswith("other") and not resolved: flags.append({"row": rn, "who": who, "issue": "city=Other (resolve into 'City (New)' from their text)"}) if email: seen_email.setdefault(email, []).append(rn) diff --git a/skills/aaif-clean-data/scripts/test_clean.py b/skills/aaif-clean-data/scripts/test_clean.py index 34bb7f8..0cfbe40 100644 --- a/skills/aaif-clean-data/scripts/test_clean.py +++ b/skills/aaif-clean-data/scripts/test_clean.py @@ -80,11 +80,23 @@ def test_base_accounts_for_stale_deleted_above_red(self): class TestFormulaConsistency(unittest.TestCase): def test_detected_set_equals_installed_formulas(self): - # Guards the idempotency contract: the set used to find our rules must be - # exactly the formulas we install. + # Guards the idempotency contract: the set used to find our rules must + # cover the formulas we install (plus retired ones, matched as stale). self.assertEqual(clean.COLOR_FORMULAS, {clean.VIOLET_FORMULA, clean.AMBER_FORMULA, clean.GREEN_FORMULA}) + self.assertEqual(clean.STALE_COLOR_FORMULAS, + clean.COLOR_FORMULAS | clean.LEGACY_COLOR_FORMULAS) self.assertEqual(clean.AMBER_FORMULA, '=$H2<>""') + # Green must reject ANY "Other..." placeholder, not just the bare "Other". + self.assertEqual(clean.GREEN_FORMULA, '=AND($G2<>"",LEFT($G2,5)<>"Other")') + + def test_legacy_green_rule_matched_stale(self): + # A rule installed by an earlier release (old green formula) must be + # deleted on refresh, not left to stack next to the new one. + legacy = _our_rule('=AND($G2<>"",$G2<>"Other")') + stale, base = clean.color_rule_plan([_red_rule(), legacy]) + self.assertEqual(stale, [1]) + self.assertEqual(base, 1) if __name__ == "__main__": diff --git a/skills/aaif-create-chapter/scripts/create_chapter.py b/skills/aaif-create-chapter/scripts/create_chapter.py index 2a2d573..d03ed4d 100755 --- a/skills/aaif-create-chapter/scripts/create_chapter.py +++ b/skills/aaif-create-chapter/scripts/create_chapter.py @@ -364,7 +364,9 @@ def gws_json(*args, params=None, body=None): if body is not None: cmd += ["--json", json.dumps(body)] out = _gws(cmd) - s = "\n".join(l for l in out.splitlines() if "keyring backend" not in l).strip() + # Split on "\n" only — NOT splitlines(), which also splits on U+2028 and + # friends INSIDE JSON string values, corrupting them when rejoined. + s = "\n".join(l for l in out.split("\n") if "keyring backend" not in l).strip() if not s: # Empty-but-successful stdout would silently become {} -> an empty file # list -> a subtree that fails to clone while the run still says "Done". diff --git a/skills/aaif-create-event/SKILL.md b/skills/aaif-create-event/SKILL.md index 40fcc9f..92aee0f 100644 --- a/skills/aaif-create-event/SKILL.md +++ b/skills/aaif-create-event/SKILL.md @@ -1,6 +1,6 @@ --- name: aaif-create-event -description: Create a new event in an AAIF chapter or online series by cloning the example section in its Event Tracker.docx and stamping all phase task due-dates from the event date. Use when asked to add/schedule/set up a new event for a chapter or series. +description: Create a new event in an AAIF chapter or online series by cloning the example section in its Event Tracker.docx and stamping all phase task due-dates from the event date; can then create the live Luma event page from the entry (proposal shown first, created only on explicit user approval). Use when asked to add/schedule/set up a new event for a chapter or series, or to put an event on Luma. argument-hint: ' --title "..." --date "..."' --- @@ -58,3 +58,43 @@ deterministic docx edit on a local file.** Prereq: `gws` installed and authentic ``` Use `--dry-run` in step 3 first if you want to preview without modifying the local file. + +## Put the event on Luma (LIVE — always confirm first) + +Every event needs a Luma page. `scripts/luma_push.py` creates it on the +chapter/series calendar from the tracker entry — **when Luma is connected**, +i.e. that calendar's API key is in `LUMA_API_KEY` or the keychain +(`security add-generic-password -s luma-api-key -a aaif -w THE_KEY`; Luma Plus, +keys are per-calendar). The script detects this itself and its dry-run says so: + +- **Connected** → show the user the printed proposal; on their explicit approval + (and ONLY then — Luma is live and guest-facing) re-run with `--create`. +- **Not connected** → do NOT try to work around it: skip the automated push and + ask the user to create the page manually at luma.com using the proposal's + details, then record the URL in the tracker's LUMA URL field (or set up the + key and re-run). + +1. **Prepare the assets**: write the page copy with `aaif-luma-description` and + save it as markdown; export the event banner to PNG for the cover + (`soffice --headless --convert-to png Banner.pptx`). Determine the city's IANA + timezone yourself and include it in the proposal for the user to check. + +2. **Propose (default, sends nothing):** + ``` + python3 ${CLAUDE_SKILL_DIR}/scripts/luma_push.py tracker.docx "Eval Night · Builder Series" \ + --timezone America/Los_Angeles --description-file luma.md --cover banner.png \ + --host "maya@example.com" --host "vol@example.com:check-in" + ``` + It prints the full payload, hosts, and which calendar the API key targets. + Show all of it to the user. The start time comes from the first `HH:MM` in + DATE & TIME (a second one is the end time; else `--duration-hours`, default 3). + It aborts if the tracker's LUMA URL already holds an event page (`--force` to override). + +3. **Create — only after the user says yes:** re-run the same command with + `--create`. It uploads the cover, creates the event, adds hosts, writes the + new event URL into the tracker's LUMA URL field (re-upload the docx to Drive), + and prints the URL. + +4. **Verify**: open the printed URL and check name/time/venue/cover/description + against the proposal; confirm the hosts appear. Later detail changes go + through `aaif-update-event` (which diffs against the live page), not a re-push. diff --git a/skills/aaif-create-event/scripts/luma_push.py b/skills/aaif-create-event/scripts/luma_push.py new file mode 100755 index 0000000..9ed29d3 --- /dev/null +++ b/skills/aaif-create-event/scripts/luma_push.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Create the Luma event page for a tracker event — proposal by default, LIVE +write only with --create (which the agent runs only after the user approves the +printed proposal). Operates on a tracker.docx the agent already downloaded via +gws; the Luma API key is per-calendar, so events land on that chapter/series +calendar (see the SKILL.md for key setup). + +Without --create: prints the full events/create payload, the hosts to add, and +which account/calendar the API key belongs to. No live writes are sent (when +connected, the dry-run still makes read-only calls: the LUMA URL entity lookup +and get_calendar). + +With --create: uploads the cover (if given), creates the event, adds hosts, +writes the event URL into the tracker's LUMA URL field (local file — re-upload +it to Drive afterwards), and prints the live URL. +""" +import argparse +import json +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[3] / "lib")) +from aaif_meetups import luma, office, tracker # noqa: E402 + + +def parse_host(spec): + """email[:manager|check-in][:Display Name] -> (email, access_level, name).""" + parts = spec.split(":", 2) + email, level, name = parts[0].strip(), None, None + for p in parts[1:]: + p = p.strip() + if p.lower() in ("manager", "check-in"): + level = p.lower() + elif p: + name = p + if "@" not in email: + raise ValueError("--host needs an email, got %r" % spec) + return email, level, name + + +def already_pushed(view, *, connected=False): + """The LUMA URL cell holds an event page link (not the chapter calendar link). + + The template pre-fills the chapter calendar link (an aaif- slug), but event + pages may use aaif- slugs too (--slug aaif-sf-evalnight). When connected, + the entity lookup decides — and a failed lookup raises LumaError rather than + guessing, so --create can't proceed on an unverified URL. Offline (no key), + the slug heuristic decides; safe, because --create requires the key. + """ + cell = (view["details"].get("LUMA URL") or "").strip() + if not cell or not ("luma.com/" in cell.lower() or "lu.ma/" in cell.lower()): + return None + if connected: + try: + luma.resolve_event_id(cell) + return cell + except luma.NotAnEventUrl: + return None + return None if "aaif-" in cell.lower() else cell + + +def show_proposal(payload, hosts, cover, key_ok): + print("Proposed Luma event (events/create):") + print(json.dumps(payload, indent=2, ensure_ascii=False)) + if cover: + print("Cover : %s (uploaded to the Luma CDN on --create)" % cover) + for email, level, name in hosts: + print("Host : %s (%s%s)" % (email, level or "manager", ", " + name if name else "")) + if key_ok: + try: + cal = luma.get_calendar() + print("Target: calendar %r — events created by this key land THERE." + % (cal.get("name") or cal)) + except luma.LumaError as e: + print("Target: could not read the calendar for this key (%s)" % e) + else: + print("Target: Luma NOT connected — no API key for this calendar.") + + +def main(): + ap = argparse.ArgumentParser(description="Push a tracker event to Luma (propose, then --create)") + ap.add_argument("docx", help="tracker.docx already downloaded via gws") + ap.add_argument("event", help="event title (case-insensitive substring), or 'next'/'latest'") + ap.add_argument("--timezone", required=True, + help="IANA timezone of the event city, e.g. America/Los_Angeles") + ap.add_argument("--duration-hours", type=float, default=3.0, + help="event length when DATE & TIME has no end time (default 3)") + ap.add_argument("--description-file", help="markdown file for the page copy " + "(write it with the aaif-luma-description skill)") + ap.add_argument("--cover", help="local cover image (export the event banner to PNG)") + ap.add_argument("--slug", help="optional event URL slug") + ap.add_argument("--host", action="append", default=[], metavar="EMAIL[:LEVEL][:NAME]", + help="host to add (repeatable); LEVEL = manager (default) or check-in") + ap.add_argument("--create", action="store_true", + help="LIVE WRITE: create the event on Luma (only after user approval)") + ap.add_argument("--force", action="store_true", + help="create even though the tracker already has an event URL") + a = ap.parse_args() + + root = office.read_document(a.docx) + view = tracker.read_event(root, a.event) + description = open(a.description_file, encoding="utf-8").read() if a.description_file else None + payload = luma.event_payload(view, a.timezone, a.duration_hours, + description_md=description, slug=a.slug) + hosts = [parse_host(h) for h in a.host] + + key_ok = luma.available() + try: + pushed = already_pushed(view, connected=key_ok) + except luma.LumaError as e: + if not a.force: + sys.exit("ABORT: couldn't verify whether the LUMA URL is already an event " + "page (%s). Retry, or pass --force to skip the check." % e) + pushed = None + print("WARNING: couldn't verify the LUMA URL (%s) — continuing due to --force." % e) + if pushed and not a.force: + sys.exit("ABORT: this event's LUMA URL is already %r — it looks pushed. " + "Use aaif-update-event to change it, or --force to create anyway." % pushed) + + show_proposal(payload, hosts, a.cover, key_ok) + if not a.create: + if key_ok: + print("\n[dry-run] No live writes sent. Review with the user; re-run with " + "--create ONLY after they explicitly approve this proposal.") + else: + print("\n[dry-run] Luma is NOT connected (no API key for this calendar). " + "SKIP the automated push: ask the user to create the event manually " + "at luma.com with the details above, then record its URL in the " + "tracker's LUMA URL field. (Or set up the key — see SKILL.md — and re-run.)") + return + if not key_ok: + sys.exit("ABORT: --create needs an API key; Luma is not connected. Create the " + "event manually instead (details above) or set up the key per SKILL.md.") + + print("\nLIVE: creating the event on Luma...") + if a.cover: + payload["cover_url"] = luma.upload_image(a.cover) + print(" cover uploaded: %s" % payload["cover_url"]) + res = luma.create_event(payload) + event_id = res.get("id") or res.get("api_id") # same variance resolve_event_id handles + if not event_id: + sys.exit("Event was likely created, but the response had no id (keys: %s) — " + "check the calendar, then record the page URL in the tracker's " + "LUMA URL field manually." % ", ".join(sorted(res))) + try: + live = luma.get_event(event_id) + except luma.LumaError as e: + live = {} + print(" !! created %s but couldn't re-fetch it: %s" % (event_id, e)) + url = live.get("url") or res.get("url") + print(" created: %s (%s)" % (url or "(no url in response)", event_id)) + + failed = [] + for email, level, name in hosts: + try: + luma.add_host(event_id, email, name=name, access_level=level) + print(" host added: %s" % email) + except luma.LumaError as e: + failed.append((email, str(e))) + print(" !! host FAILED: %s — %s" % (email, e)) + + if url: + tracker.set_field(root, a.event, "LUMA URL", url) + office.save_document(a.docx, root, a.docx) + print(" tracker LUMA URL set in %s — re-upload it to Drive via gws." % a.docx) + else: + # never write a guessed URL — it would poison later sync/stats resolution + print(" !! no event URL in the API response — open the calendar, find the " + "event, and record its page URL in the tracker's LUMA URL field manually.") + if failed: + sys.exit("Event created, but %d host(s) failed — add them on the Luma page " + "or re-run add-host." % len(failed)) + print("Done. Verify the page: %s" % url) + + +if __name__ == "__main__": + main() diff --git a/skills/aaif-create-event/scripts/test_luma_push.py b/skills/aaif-create-event/scripts/test_luma_push.py new file mode 100755 index 0000000..2fdbeab --- /dev/null +++ b/skills/aaif-create-event/scripts/test_luma_push.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +import pathlib +import sys +import unittest +from unittest import mock + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +import luma_push # noqa: E402 + + +def view(url): + return {"details": {"LUMA URL": url}} + + +class TestParseHost(unittest.TestCase): + def test_forms(self): + self.assertEqual(luma_push.parse_host("a@b.co"), ("a@b.co", None, None)) + self.assertEqual(luma_push.parse_host("a@b.co:check-in"), ("a@b.co", "check-in", None)) + self.assertEqual(luma_push.parse_host("a@b.co:manager:Maya Chen"), + ("a@b.co", "manager", "Maya Chen")) + self.assertEqual(luma_push.parse_host("a@b.co:Maya Chen"), + ("a@b.co", None, "Maya Chen")) + + def test_levels_match_case_insensitively(self): + # "Check-In" must not silently become a display name with manager access + self.assertEqual(luma_push.parse_host("a@b.co:Check-In"), ("a@b.co", "check-in", None)) + self.assertEqual(luma_push.parse_host("a@b.co:MANAGER:Maya Chen"), + ("a@b.co", "manager", "Maya Chen")) + + def test_rejects_non_email(self): + with self.assertRaises(ValueError): + luma_push.parse_host("not-an-email") + + +class TestAlreadyPushed(unittest.TestCase): + def test_event_page_url_counts_as_pushed(self): + self.assertEqual(luma_push.already_pushed(view("https://luma.com/ia70fwmm")), + "https://luma.com/ia70fwmm") + self.assertTrue(luma_push.already_pushed(view("lu.ma/xyz123"))) + + def test_calendar_link_or_empty_does_not(self): + # the template pre-fills the CHAPTER calendar link; that's not a pushed event + self.assertIsNone(luma_push.already_pushed(view("luma.com/aaif-sanfrancisco"))) + self.assertIsNone(luma_push.already_pushed(view(""))) + self.assertIsNone(luma_push.already_pushed(view("TBD"))) + + def test_connected_aaif_event_slug_counts_as_pushed(self): + # event pages may use aaif- slugs too — the entity lookup, not the slug, + # decides, so an aaif- event page must NOT bypass the duplicate-create abort + with mock.patch.object(luma_push.luma, "resolve_event_id", return_value="evt-1"): + self.assertEqual( + luma_push.already_pushed(view("https://luma.com/aaif-sf-evalnight"), + connected=True), + "https://luma.com/aaif-sf-evalnight") + + def test_connected_calendar_link_does_not(self): + with mock.patch.object(luma_push.luma, "resolve_event_id", + side_effect=luma_push.luma.NotAnEventUrl("calendar")): + self.assertIsNone(luma_push.already_pushed(view("luma.com/aaif-sanfrancisco"), + connected=True)) + + def test_connected_lookup_failure_raises_not_guesses(self): + # fail closed: an inconclusive lookup must surface, never silently fall + # back to the slug heuristic right before a live --create + with mock.patch.object(luma_push.luma, "resolve_event_id", + side_effect=luma_push.luma.LumaError("HTTP 500")): + with self.assertRaises(luma_push.luma.LumaError): + luma_push.already_pushed(view("https://luma.com/aaif-sf-evalnight"), + connected=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/aaif-create-online-series/scripts/create_series.py b/skills/aaif-create-online-series/scripts/create_series.py index 5bc0938..2f16ce7 100755 --- a/skills/aaif-create-online-series/scripts/create_series.py +++ b/skills/aaif-create-online-series/scripts/create_series.py @@ -193,7 +193,9 @@ def gws_json(*args, params=None, body=None): if body is not None: cmd += ["--json", json.dumps(body)] out = _gws(cmd) - s = "\n".join(l for l in out.splitlines() if "keyring backend" not in l).strip() + # Split on "\n" only — NOT splitlines(), which also splits on U+2028 and + # friends INSIDE JSON string values, corrupting them when rejoined. + s = "\n".join(l for l in out.split("\n") if "keyring backend" not in l).strip() if not s: # Empty-but-successful stdout would silently become {} -> an empty file # list -> a subtree that fails to clone while the run still says "Done". diff --git a/skills/aaif-event-status/SKILL.md b/skills/aaif-event-status/SKILL.md index e9321c5..4f402a8 100644 --- a/skills/aaif-event-status/SKILL.md +++ b/skills/aaif-event-status/SKILL.md @@ -1,6 +1,6 @@ --- name: aaif-event-status -description: Report task status for an AAIF chapter or online series — which event tasks are overdue or due soon, grouped by owner, read from the Event Tracker.docx. Use when asked for the status / health / what's-due of a chapter or series' events. +description: Report task status for an AAIF chapter or online series — which event tasks are overdue or due soon, grouped by owner, read from the Event Tracker.docx — plus read-only Luma registration stats (going/waitlist/checked-in counts) for pushed events. Use when asked for the status / health / what's-due / RSVP numbers of a chapter or series' events. argument-hint: ' [event]' --- @@ -41,3 +41,23 @@ deterministic parsing of a local file.** Prereq: `gws` installed and authenticat Status is computed against today from each task's DUE cell; clock-time day-of tasks and `Done` tasks are excluded. Nothing is written back — this skill only reads. + +## Luma registration stats (read-only) + +For events whose tracker LUMA URL holds their event page (written by +`aaif-create-event`'s Luma push), pull the live guest counts — going, pending, +waitlist, invited, declined, checked-in — plus registration state: + +``` +python3 ${CLAUDE_SKILL_DIR}/scripts/luma_stats.py tracker.docx ["event"] +python3 ${CLAUDE_SKILL_DIR}/scripts/luma_stats.py --url https://luma.com/EVENT_SLUG +``` + +The script detects whether Luma is connected (that calendar's API key in +`LUMA_API_KEY` or keychain item `luma-api-key`; see `aaif-create-event` for +setup); when it isn't, it skips the stats with a note — the task digest above +still works, and the user can read the numbers off the event page manually. +This is strictly read-only — +it never writes to Luma, the tracker, or any sheet, and Luma data is never fed +back into the Intake Ops sheet. Use the numbers for the day-of slides +(`aaif-dayof-slides`) and the recap post (`aaif-recap-post`). diff --git a/skills/aaif-event-status/scripts/luma_stats.py b/skills/aaif-event-status/scripts/luma_stats.py new file mode 100755 index 0000000..e3f63d3 --- /dev/null +++ b/skills/aaif-event-status/scripts/luma_stats.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Read-only Luma registration stats for tracker events: guest counts by status +(going / pending / waitlist / invited / declined / checked-in), registration +state, and the event URL. Never writes anything, on Luma or locally. + +Targets come from a tracker.docx (each event's LUMA URL field, as written back +by aaif-create-event's luma_push) or directly via --url / --event-id. +""" +import argparse +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[3] / "lib")) +from aaif_meetups import luma, office, tracker # noqa: E402 + +COUNT_KEYS = ("approved", "pending_approval", "waitlist", "invited", "declined", "checked_in") + + +def print_stats(live, label=None): + counts = live.get("guest_counts") + print("== %s ==" % (label or live.get("name", "?"))) + print(" %s · starts %s (%s)" % (live.get("url", "?"), + live.get("start_at", "?"), live.get("timezone", "?"))) + print(" registration: %s · waitlist: %s" + % ("OPEN" if live.get("registration_open") else "closed", + live.get("waitlist_status", "?"))) + if counts is None: + # absent != zero RSVPs — don't print a fake 0 + print(" (no guest counts in the API response)") + return + going = (counts.get("approved") or {}).get("guests", 0) + print(" going: %d %s" % (going, " ".join( + "%s: %d" % (k.replace("_", "-"), (counts.get(k) or {}).get("guests", 0)) + for k in COUNT_KEYS if k != "approved"))) + + +def stats_for_tracker(docx, event_filter): + root = office.read_document(docx) + refs = tracker.list_events(root) + if event_filter: + refs = [e for e in refs if event_filter.lower() in e["title"].lower()] + shown, errors = 0, 0 + for ref in refs: + view = tracker.view_event(ref) + cell = (view["details"].get("LUMA URL") or "").strip() + if not cell: + print("== %s ==\n (no event page in LUMA URL — not pushed to Luma yet)" + % view["title"]) + continue + # Event pages may use aaif- slugs, so the entity lookup — not the slug — + # decides whether the cell holds an event page or the calendar link. + try: + live = luma.get_event(luma.resolve_event_id(cell)) + except luma.NotAnEventUrl: + print("== %s ==\n (LUMA URL doesn't point to an event page — not pushed " + "to Luma yet)" % view["title"]) + continue + except luma.LumaError as e: + print("== %s ==\n !! %s" % (view["title"], e)) + errors += 1 + continue + print_stats(live, label=view["title"]) + shown += 1 + if not refs: + print("No matching events in %s." % docx) + if errors: + print("!! %d event(s) errored — stats above are incomplete." % errors) + return shown, errors + + +def main(): + ap = argparse.ArgumentParser(description="Read-only Luma guest stats for AAIF events") + ap.add_argument("docx", nargs="?", help="tracker.docx already downloaded via gws") + ap.add_argument("event", nargs="?", help="optional event title filter; default all") + ap.add_argument("--url", help="a luma.com event URL to check directly") + ap.add_argument("--event-id", help="an evt- id to check directly") + a = ap.parse_args() + + if not luma.available(): + # Not connected: the task digest still works; just no registration numbers. + print("Luma is not connected (no API key) — skipping registration stats. " + "Read the numbers off the event page manually, or set up the key " + "(see aaif-create-event's SKILL.md).") + return + if a.event_id: + print_stats(luma.get_event(a.event_id)) + elif a.url: + print_stats(luma.get_event(luma.resolve_event_id(a.url))) + elif a.docx: + _, errors = stats_for_tracker(a.docx, a.event) + if errors: + sys.exit(1) # partial digest — don't let the caller read it as complete + else: + ap.error("pass a tracker.docx, --url, or --event-id") + + +if __name__ == "__main__": + main() diff --git a/skills/aaif-sync-chapters/SKILL.md b/skills/aaif-sync-chapters/SKILL.md new file mode 100644 index 0000000..05b9939 --- /dev/null +++ b/skills/aaif-sync-chapters/SKILL.md @@ -0,0 +1,95 @@ +--- +name: aaif-sync-chapters +description: Sync accepted/existing organizers from the Intake Ops sheet into the Chapters List — merge names into each city's Organizers column and add rows for net-new cities. Reports & proposes by default; only writes on explicit approval. Use when asked to sync organizers/chapters or push intake decisions to the chapters list. +argument-hint: "[--write]" +--- + +# Sync Intake Organizers → Chapters List + +Push organizer decisions from the **AAIF Community Intake Ops** sheet +(id `1cWkjCI5AGK9RX_fs23P5jRA4I2nixgnHuapvwHseZ5o`, tab `Organizers`) into the +**AAIF Community Chapters List** (id `18_7aHD45-5NhlN6IZKW2QzswZlDHVb8nBSP7rl5-yWg`, +tab `Chapters & Teams`): every organizer whose Status is **`Accepted`** or +**`Existing (from MLOps)`** must appear in their city row's **Organizers** column, +and cities with no row yet get one appended. The intake sheet is only ever **read**; +all writes go to the chapters list. Idempotent — a second run right after a sync +proposes zero changes. + +Prereq: the `gws` CLI must be installed and authenticated (see the user's +`gws-cli-access` memory). + +## The flow: report → approve → write + +1. **Report (default, read-only):** + ```bash + python3 ${CLAUDE_SKILL_DIR}/scripts/sync_chapters.py + ``` + Shows per-city adds to existing rows (with the exact new B value), proposed + new city rows (appended row number + Luma slug + whether the page is live), + near-miss city names, unresolved-city rows, and deduped duplicates. Ends with + a "No changes needed" line when the sheets are already in sync. + +2. **Show the user the proposal** and get explicit approval. Never skip to write. + +3. **Write (on approval only):** + ```bash + python3 ${CLAUDE_SKILL_DIR}/scripts/sync_chapters.py --write + ``` + Recomputes the proposal from a **fresh read** (a stale proposal is never + applied), applies everything in **one** `values batchUpdate` (a partial + failure can't half-sync the sheet), then re-reads and verifies a fresh run + proposes zero changes (exits non-zero otherwise). + +## Sync rules (what the engine does) + +- **Status filter is exact-string**: `Accepted` and `Existing (from MLOps)` only. + (Matching a prefix like `Existing` once missed all 23 MLOps rows.) +- **City resolution per intake row**: `City (New)` wins if non-empty; else + `City (Existing)` unless it's an `Other…` placeholder; else the row is + **unresolved** — reported with its free-text answers quoted (and an inferred + city when the text explicitly names a chapter city), **never written**. The fix + is to fill `City (New)` on the intake row (see `aaif-clean-data`), then re-run. +- **Merge, don't overwrite**: existing `B` is parsed on `;`, intake names are + appended only if missing (compared case-, whitespace- and accent-insensitively); + names already in `B` but absent from intake are left alone (manual entries live + there). Written values keep original UTF-8 (`Médéric Hurier` stays accented). +- **Near-miss cities** (e.g. intake `Delhi` vs row `Delhi NCR`) are reported, not + auto-matched — confirm the right row or fix the intake city, never create a + near-duplicate row. +- **Column C (`Previous MLOps Organizers`) is read-only history — never modified.** + Its spellings can differ from intake (e.g. "Adam Lite" vs "Adam Liter"); intake + wins for column B. San Francisco people are **not** mirrored into the Silicon + Valley row — B follows the intake city; C is where the legacy duplication lives. +- **New city rows** are appended after the last non-empty City row (not at the + grid bottom): City in A, names joined `"; "` in B, C empty, and + `https://luma.com/aaif-SLUG` in D (slug = city lowercased, spaces/accents + removed; same exceptions as `aaif-create-chapter`, e.g. Denver → `aaif-colorado`). + The report says whether the Luma page is live — page creation is manual, and a + net-new city still needs its Drive folder/assets: run **`aaif-create-chapter`** + for it as the follow-up. +- Duplicate intake rows for the same person+city are deduped (first wins, reported). + +## Verify + +After any run (and after editing the engine): + +- The report's intake counts should match a manual count of the sheet's Status + column; a delta means status strings drifted. +- After `--write`, the built-in re-verify must print + "Verified: a fresh run proposes zero changes." +- Spot-check one touched row in the sheet: B merged correctly, C and D untouched, + and the version history shows a single edit for the whole sync. +- Unit tests for the pure merge/slug/near-miss logic: + ```bash + python3 ${CLAUDE_SKILL_DIR}/scripts/test_sync_chapters.py + ``` + +## Notes + +- Both tabs are read by **header name** (`Status`, `Full name`, `City (Existing)`, + `City (New)`, `Run events before?`, `Why organize / ties`, `City`, `Organizers`), + never by fixed column letter — the script aborts loudly if a header disappears. +- Quote the tab name in any manual A1 ranges (`'Chapters & Teams'!B11`) — it + contains `&` and spaces. +- Unresolved rows already hand-placed on the chapters list are flagged + "no action needed" so they don't nag every run. diff --git a/skills/aaif-sync-chapters/scripts/sync_chapters.py b/skills/aaif-sync-chapters/scripts/sync_chapters.py new file mode 100755 index 0000000..b2bc8f5 --- /dev/null +++ b/skills/aaif-sync-chapters/scripts/sync_chapters.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +"""Sync organizer decisions from the AAIF Community Intake Ops sheet into the +AAIF Community Chapters List. + +Every intake organizer whose Status is "Accepted" or "Existing (from MLOps)" +must appear in the Organizers column of their city's row on the chapters list; +cities with no row yet get one appended. The intake sheet is only ever READ. + +Usage: + python3 sync_chapters.py # report + proposed changes, writes nothing + python3 sync_chapters.py --write # apply the proposal via one batchUpdate + +The report shows: per-city name adds (existing rows), new rows with their +appended row numbers and Luma slugs, unresolved-city rows needing a human, +near-miss city names (never auto-matched), and a "no changes" line when the +sheets are already in sync. --write recomputes the proposal from a fresh read, +applies it atomically, then re-reads and verifies the diff is empty. +""" +import argparse, json, re, subprocess, sys, time, unicodedata, urllib.error, urllib.request + +INTAKE_ID = "1cWkjCI5AGK9RX_fs23P5jRA4I2nixgnHuapvwHseZ5o" +INTAKE_TAB = "Organizers" +CHAPTERS_ID = "18_7aHD45-5NhlN6IZKW2QzswZlDHVb8nBSP7rl5-yWg" +CHAPTERS_TAB = "Chapters & Teams" + +# Exact dropdown strings — "Existing" alone would miss every MLOps row. +SYNC_STATUSES = ("Accepted", "Existing (from MLOps)") + +# Folded city -> Luma slug, for cities whose page doesn't follow the default +# slug rule (same exceptions as aaif-create-chapter). +SLUG_OVERRIDES = {"denver": "colorado"} + +# ---------------------------------------------------------------------------- +# gws helpers (same retry/JSON pattern as aaif-create-chapter) +# ---------------------------------------------------------------------------- +_TRANSIENT = ("timed out", "internalError", "HTTP request failed", + "Connection", "temporarily", "rateLimit", "userRateLimit", + "backendError", "503", "500", "502") + +def _gws(cmd, retries=5): + for i in range(max(1, retries)): # retries<=0 must raise below, not return None + r = subprocess.run(cmd, capture_output=True, text=True) + if r.returncode == 0: + return r.stdout + msg = (r.stderr or "") + (r.stdout or "") + if i < max(1, retries) - 1 and any(k in msg for k in _TRANSIENT): + time.sleep(2 * (i + 1)) + continue + raise RuntimeError("gws failed (%s): %s" % (r.returncode, msg.strip()[:400])) + +def gws_json(*args, params=None, body=None): + cmd = ["gws", *args] + if params is not None: + cmd += ["--params", json.dumps(params)] + if body is not None: + cmd += ["--json", json.dumps(body)] + out = _gws(cmd) + # Split on "\n" only — NOT splitlines(), which also splits on U+2028 and + # friends INSIDE cell values, corrupting the JSON when rejoined. + s = "\n".join(l for l in out.split("\n") if "keyring backend" not in l).strip() + if not s: + raise RuntimeError("gws produced no JSON output for: %s" % " ".join(args)) + try: + return json.loads(s) + except json.JSONDecodeError: + raise RuntimeError("gws returned non-JSON output for %s: %s" % (" ".join(args), s[:200])) + +def get_values(sheet_id, rng): + res = gws_json("sheets", "spreadsheets", "values", "batchGet", + params={"spreadsheetId": sheet_id, "ranges": [rng]}) + return res["valueRanges"][0].get("values", []) + +# ---------------------------------------------------------------------------- +# Text helpers +# ---------------------------------------------------------------------------- +def fold(s): + """Comparison key: accent-folded, casefolded, whitespace-collapsed. + Only ever used to COMPARE — written values keep their original UTF-8.""" + s = unicodedata.normalize("NFKD", s) + s = "".join(c for c in s if not unicodedata.combining(c)) + return re.sub(r"\s+", " ", s).strip().casefold() + +def slugify(city): + s = unicodedata.normalize("NFKD", city).encode("ascii", "ignore").decode() + return SLUG_OVERRIDES.get(fold(city), re.sub(r"[^a-z0-9]", "", s.lower())) + +def cell(row, i): + return row[i].strip() if i < len(row) and isinstance(row[i], str) else "" + +def header_index(headers, sheet, *names): + idx = [] + for n in names: + if n not in headers: + sys.exit("ABORT: column %r not found on %s — sheet layout changed?" % (n, sheet)) + idx.append(headers.index(n)) + return idx + +def luma_status(slug): + """'live' (200) / 'absent' (404) / 'unknown' (couldn't verify).""" + req = urllib.request.Request("https://luma.com/aaif-" + slug, method="GET", + headers={"User-Agent": "Mozilla/5.0"}) + try: + with urllib.request.urlopen(req, timeout=15) as r: + return "live" if r.status == 200 else "unknown" + except urllib.error.HTTPError as e: + return "absent" if e.code == 404 else "unknown" + except (urllib.error.URLError, TimeoutError): + return "unknown" + +# ---------------------------------------------------------------------------- +# Read the two sheets +# ---------------------------------------------------------------------------- +def read_intake(): + """Return (entries, unresolved, status_counts, dupes). + + entries = [{row, name, city, status}] city resolved, deduped + unresolved = [{row, name, status, g, h, events, why}] needs a human + """ + rows = get_values(INTAKE_ID, "%s!A:U" % INTAKE_TAB) + if not rows: + sys.exit("ABORT: intake tab %r came back empty." % INTAKE_TAB) + i_status, i_name, i_g, i_h, i_events, i_why = header_index( + rows[0], INTAKE_TAB, "Status", "Full name", "City (Existing)", "City (New)", + "Run events before?", "Why organize / ties") + + entries, unresolved, dupes = [], [], [] + counts = {s: 0 for s in SYNC_STATUSES} + seen = set() + for rownum, row in enumerate(rows[1:], start=2): + status = cell(row, i_status) + if status not in SYNC_STATUSES: + continue + counts[status] += 1 + name = cell(row, i_name) + g, h = cell(row, i_g), cell(row, i_h) + # City resolution: City (New) wins; else City (Existing) unless it's an + # "Other..." placeholder; else the row needs a human. + city = h or (g if g and not fold(g).startswith("other") else "") + if not name or not city: + unresolved.append({"row": rownum, "name": name, "status": status, + "g": g, "h": h, + "events": cell(row, i_events), "why": cell(row, i_why)}) + continue + key = (fold(name), fold(city)) + if key in seen: + dupes.append({"row": rownum, "name": name, "city": city}) + continue + seen.add(key) + entries.append({"row": rownum, "name": name, "city": city, "status": status}) + return entries, unresolved, counts, dupes + +def read_chapters(): + """Return (chapters, last_row). chapters = [{row, city, organizers_raw}].""" + rows = get_values(CHAPTERS_ID, "'%s'!A:D" % CHAPTERS_TAB) + if not rows: + sys.exit("ABORT: chapters tab %r came back empty." % CHAPTERS_TAB) + i_city, i_org = header_index(rows[0], CHAPTERS_TAB, "City", "Organizers") + + chapters, last_row = [], 1 + for rownum, row in enumerate(rows[1:], start=2): + city = cell(row, i_city) + if not city: + continue # never append into a gap; find the true last City row + chapters.append({"row": rownum, "city": city, "organizers_raw": cell(row, i_org)}) + last_row = rownum + return chapters, last_row + +# ---------------------------------------------------------------------------- +# Diff +# ---------------------------------------------------------------------------- +def parse_organizers(raw): + return [p.strip() for p in raw.split(";") if p.strip()] + +def build_proposal(entries, chapters, last_row): + """Return (adds, new_rows, near_misses). + + adds = [{row, city, names, new_value}] merge into an existing B cell + new_rows = [{row, city, names, slug}] append after last_row + near_misses= [{city, names, candidates}] no exact row; never written + """ + by_city = {} # folded intake city -> {city, names[]} (intake order) + for e in entries: + by_city.setdefault(fold(e["city"]), {"city": e["city"], "names": []})["names"].append(e["name"]) + + chap_by_fold = {fold(c["city"]): c for c in chapters} + adds, new_rows, near_misses = [], [], [] + next_row = last_row + 1 + for fc, grp in by_city.items(): + chap = chap_by_fold.get(fc) + if chap: + existing = parse_organizers(chap["organizers_raw"]) + present = {fold(n) for n in existing} + # Merge, don't overwrite: keep every name already in B (manual + # entries included), append only the intake names missing from it. + missing = [n for n in grp["names"] if fold(n) not in present] + if missing: + adds.append({"row": chap["row"], "city": chap["city"], "names": missing, + "new_value": "; ".join(existing + missing)}) + continue + cands = [c for c in chapters if fc in fold(c["city"]) or fold(c["city"]) in fc] + if cands: + near_misses.append({"city": grp["city"], "names": grp["names"], + "candidates": [(c["city"], c["row"]) for c in cands]}) + continue + new_rows.append({"row": next_row, "city": grp["city"], "names": grp["names"], + "slug": slugify(grp["city"])}) + next_row += 1 + return adds, new_rows, near_misses + +def annotate_unresolved(unresolved, chapters): + """Mark unresolved rows already hand-placed on the chapters list, and infer + a city ONLY when the row's free text explicitly names a chapter city.""" + for u in unresolved: + u["placed"] = [(c["city"], c["row"]) for c in chapters + if fold(u["name"]) in {fold(n) for n in parse_organizers(c["organizers_raw"])} + ] if u["name"] else [] + text = fold(u["events"] + " " + u["why"]) + u["inferred"] = [c["city"] for c in chapters + if re.search(r"(? %r" % (a["row"], a["new_value"])) + if new_rows: + print("\nProposed NEW city rows (appended after row %d):" % last_row) + for n in new_rows: + status = luma_status(n["slug"]) + note = {"live": "Luma page live", + "absent": "Luma page NOT LIVE yet — create it manually; run aaif-create-chapter for the assets", + "unknown": "could not verify the Luma page — check it manually"}[status] + print(" row %d: %s — %s — https://luma.com/aaif-%s (%s)" + % (n["row"], n["city"], "; ".join(n["names"]), n["slug"], note)) + if near_misses: + print("\nNear-miss cities (NOT written — confirm the right row, or fix the intake city):") + for m in near_misses: + cand = ", ".join("%r (row %d)" % c for c in m["candidates"]) + print(" intake %r (%s) ~ chapter %s" % (m["city"], "; ".join(m["names"]), cand)) + if unresolved: + print("\nUnresolved city — needs a human, never written:") + for u in unresolved: + print(" intake row %d: %s (%s) — City (Existing)=%r, City (New)=%r" + % (u["row"], u["name"] or "(no name)", u["status"], u["g"], u["h"])) + print(" Run events before?: %r" % u["events"]) + print(" Why organize / ties: %r" % u["why"]) + if u["inferred"]: + print(" -> free text names %s; fill City (New) on the intake row to sync." + % ", ".join(map(repr, u["inferred"]))) + if u["placed"]: + print(" -> already on the chapters list: %s — no action needed." + % ", ".join("%s (row %d)" % p for p in u["placed"])) + if dupes: + print("\nDuplicate intake rows (deduped, first occurrence wins):") + for d in dupes: + print(" intake row %d: %s / %s" % (d["row"], d["name"], d["city"])) + + if not adds and not new_rows: + print("\nNo changes needed — the chapters list is in sync with the intake.") + +# ---------------------------------------------------------------------------- +def compute(): + entries, unresolved, counts, dupes = read_intake() + chapters, last_row = read_chapters() + adds, new_rows, near_misses = build_proposal(entries, chapters, last_row) + annotate_unresolved(unresolved, chapters) + return entries, unresolved, counts, dupes, chapters, last_row, adds, new_rows, near_misses + +def apply_changes(adds, new_rows): + data = [{"range": "'%s'!B%d" % (CHAPTERS_TAB, a["row"]), "values": [[a["new_value"]]]} + for a in adds] + data += [{"range": "'%s'!A%d:D%d" % (CHAPTERS_TAB, n["row"], n["row"]), + "values": [[n["city"], "; ".join(n["names"]), "", + "https://luma.com/aaif-" + n["slug"]]]} + for n in new_rows] + # One batchUpdate for everything, so a partial failure can't half-sync the + # sheet. RAW, not USER_ENTERED: a name starting with = + - @ must stay text, + # never become a formula. + gws_json("sheets", "spreadsheets", "values", "batchUpdate", + params={"spreadsheetId": CHAPTERS_ID}, + body={"valueInputOption": "RAW", "data": data}) + return len(data) + +def main(): + ap = argparse.ArgumentParser(description="Sync intake organizer decisions into the chapters list.") + ap.add_argument("--write", action="store_true", + help="apply the proposed changes (default: report only)") + a = ap.parse_args() + + # --write recomputes from a fresh read here — a stale proposal is never applied. + state = compute() + print_report(*state) + adds, new_rows = state[6], state[7] + if not a.write or (not adds and not new_rows): + return + + print("\nApplying %d cell update(s) + %d new row(s) in one batchUpdate..." + % (len(adds), len(new_rows))) + n = apply_changes(adds, new_rows) + print("Wrote %d range(s)." % n) + + print("\nRe-verifying...") + _, _, _, _, _, _, adds2, new_rows2, _ = compute() + if adds2 or new_rows2: + print("VERIFY FAILED — still out of sync after write:") + for x in adds2: + print(" row %d %s: + %s" % (x["row"], x["city"], "; ".join(x["names"]))) + for x in new_rows2: + print(" new row %s: %s" % (x["city"], "; ".join(x["names"]))) + sys.exit(1) + print("Verified: a fresh run proposes zero changes.") + +if __name__ == "__main__": + main() diff --git a/skills/aaif-sync-chapters/scripts/test_sync_chapters.py b/skills/aaif-sync-chapters/scripts/test_sync_chapters.py new file mode 100755 index 0000000..65163cf --- /dev/null +++ b/skills/aaif-sync-chapters/scripts/test_sync_chapters.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Unit tests for the pure logic in sync_chapters.py (no network/gws).""" +import sys, os +from unittest import mock +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import sync_chapters +from sync_chapters import fold, slugify, parse_organizers, build_proposal + +def chap(row, city, orgs): + return {"row": row, "city": city, "organizers_raw": orgs} + +def entry(row, name, city): + return {"row": row, "name": name, "city": city, "status": "Accepted"} + +fails = 0 +def check(label, got, want): + global fails + ok = got == want + fails += 0 if ok else 1 + print("%s %s" % ("ok " if ok else "FAIL", label)) + if not ok: + print(" got : %r\n want: %r" % (got, want)) + +# --- fold: case, whitespace, accents (compare folded, write original) -------- +check("fold trims/collapses/casefolds", fold(" Chandana Srinivasa "), "chandana srinivasa") +check("fold strips accents", fold("Médéric Hurier"), fold("Mederic HURIER")) + +# --- slugify: default rule + the Denver exception ----------------------------- +check("slug default", slugify("New York"), "newyork") +check("slug accents", slugify("Montréal"), "montreal") +check("slug Denver override", slugify("Denver"), "colorado") + +# --- parse_organizers ---------------------------------------------------------- +check("parse B", parse_organizers(" Gleb Lukicov; Alex Jones ; "), ["Gleb Lukicov", "Alex Jones"]) +check("parse empty B", parse_organizers(""), []) + +CHAPTERS = [chap(2, "Boston", "Kranthi Manchikanti"), + chap(3, "Delhi NCR", ""), + chap(4, "San Francisco", "Rahul Parundekar"), + chap(5, "Silicon Valley", "")] + +# --- merge, don't overwrite: dupe detection is case/space/accent-insensitive --- +adds, new_rows, near = build_proposal( + [entry(2, "kranthi manchikanti", "Boston"), # already present (case/space) + entry(3, "New Person", "Boston")], + CHAPTERS, 5) +check("merge appends only missing", adds, + [{"row": 2, "city": "Boston", "names": ["New Person"], + "new_value": "Kranthi Manchikanti; New Person"}]) +check("merge creates no rows", (new_rows, near), ([], [])) + +# --- city match is case-insensitive; manual B entries are kept ----------------- +adds, _, _ = build_proposal([entry(2, "Ana Ruiz", " boston ")], CHAPTERS, 5) +check("city matched folded, existing name kept", adds[0]["new_value"], + "Kranthi Manchikanti; Ana Ruiz") + +# --- near-miss reported, never written ----------------------------------------- +adds, new_rows, near = build_proposal([entry(2, "Kritika Parmar", "Delhi")], CHAPTERS, 5) +check("near-miss no write", (adds, new_rows), ([], [])) +check("near-miss candidates", near, + [{"city": "Delhi", "names": ["Kritika Parmar"], + "candidates": [("Delhi NCR", 3)]}]) + +# --- SF is NOT mirrored into Silicon Valley ------------------------------------- +adds, new_rows, near = build_proposal([entry(2, "Leo Walker", "San Francisco")], CHAPTERS, 5) +check("SF row only, SV untouched", [a["row"] for a in adds], [4]) + +# --- new rows append after last non-empty row, in intake order ------------------ +adds, new_rows, _ = build_proposal( + [entry(2, "Imran Bagwan", "Pune"), entry(3, "Someone Else", "Pune"), + entry(4, "Jaime Vélez", "Montréal")], + CHAPTERS, 5) +check("new rows numbered from last+1", + [(n["row"], n["city"], n["names"], n["slug"]) for n in new_rows], + [(6, "Pune", ["Imran Bagwan", "Someone Else"], "pune"), + (7, "Montréal", ["Jaime Vélez"], "montreal")]) + +# --- empty B everywhere (first-ever run) ---------------------------------------- +adds, _, _ = build_proposal([entry(2, "A B", "Delhi NCR")], + [chap(2, "Delhi NCR", "")], 2) +check("empty B populated", adds[0]["new_value"], "A B") + +# --- apply_changes: exact ranges, column order, RAW (gws mocked, no network) ----- +with mock.patch.object(sync_chapters, "gws_json") as gj: + n = sync_chapters.apply_changes( + [{"row": 2, "city": "Boston", "names": ["New Person"], + "new_value": "Kranthi Manchikanti; New Person"}], + [{"row": 6, "city": "Pune", "names": ["Imran Bagwan"], "slug": "pune"}]) + body = gj.call_args.kwargs["body"] +check("apply_changes writes both changes", n, 2) +check("apply_changes uses RAW (no formula injection)", body["valueInputOption"], "RAW") +check("apply_changes ranges and column order", body["data"], + [{"range": "'%s'!B2" % sync_chapters.CHAPTERS_TAB, + "values": [["Kranthi Manchikanti; New Person"]]}, + {"range": "'%s'!A6:D6" % sync_chapters.CHAPTERS_TAB, + "values": [["Pune", "Imran Bagwan", "", "https://luma.com/aaif-pune"]]}]) + +# --- gws_json survives U+2028 inside JSON string values (the splitlines() bug) --- +raw = '{"a": "line1\u2028line2"}\n' +with mock.patch.object(sync_chapters.subprocess, "run", + return_value=mock.Mock(returncode=0, stdout=raw)): + check("gws_json keeps U+2028 inside values", sync_chapters.gws_json("sheets", "get"), + {"a": "line1\u2028line2"}) + +print() +sys.exit("FAIL: %d test(s) failed" % fails if fails else None) diff --git a/skills/aaif-update-event/SKILL.md b/skills/aaif-update-event/SKILL.md index 80e87cb..b1d8589 100644 --- a/skills/aaif-update-event/SKILL.md +++ b/skills/aaif-update-event/SKILL.md @@ -1,6 +1,6 @@ --- name: aaif-update-event -description: Apply a change to an existing AAIF event (chapter or series) — edit detail fields like speakers/venue/capacity, or move the date and recompute all task due-dates, then flag which marketing/banner assets are now stale. Use when asked to update/change/edit an event's details or date. +description: Apply a change to an existing AAIF event (chapter or series) — edit detail fields like speakers/venue/capacity, or move the date and recompute all task due-dates, then flag which marketing/banner assets are now stale; can also sync the change to the live Luma event page (diff shown first, pushed only on explicit user approval). Use when asked to update/change/edit an event's details or date. argument-hint: ' [--set "LABEL=value"] [--date "..."]' --- @@ -58,3 +58,33 @@ deterministic docx edit on a local file.** Prereq: `gws` installed and authentic The script prints the stale-asset list in step 2 — surface that so the organizer knows which content/banner skills to re-run. + +## Sync the change to Luma (LIVE — always confirm first) + +If the event has a Luma page (the tracker's LUMA URL holds its event URL, written +by `aaif-create-event`'s push), `scripts/luma_sync.py` diffs the tracker against +the live event and pushes only the changed fields. It detects whether Luma is +connected (that calendar's API key in `LUMA_API_KEY` or keychain item +`luma-api-key`; see `aaif-create-event` for setup): + +- **Connected** → show the user the printed diff; on their explicit approval + (and ONLY then — Luma is live, and it emails guests about changes unless + `--quiet`) re-run with `--apply`. +- **Not connected** → the script prints the desired values as a manual + checklist; pass it to the user to apply on the Luma page by hand. + +``` +# diff only (default, sends nothing) — show this to the user +python3 ${CLAUDE_SKILL_DIR}/scripts/luma_sync.py tracker.docx "Agentic AI Night" \ + --timezone Europe/Berlin + +# push, only after the user says yes; add --quiet to skip guest notifications +python3 ${CLAUDE_SKILL_DIR}/scripts/luma_sync.py tracker.docx "Agentic AI Night" \ + --timezone Europe/Berlin --apply +``` + +Add `--description-file new.md` / `--cover new.png` only when replacing those — +omitted means left alone. After `--apply` it re-fetches the event and verifies +the diff is clean. **Event cancellation is deliberately not automated** (it's +irreversible and refunds/notifies everyone) — if the user asks to cancel, point +them to the Luma page. diff --git a/skills/aaif-update-event/scripts/luma_sync.py b/skills/aaif-update-event/scripts/luma_sync.py new file mode 100755 index 0000000..38dd7bf --- /dev/null +++ b/skills/aaif-update-event/scripts/luma_sync.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Sync a tracker event's details to its live Luma event — diff by default, LIVE +write only with --apply (which the agent runs only after the user approves the +printed diff). Reads a tracker.docx the agent already downloaded via gws. + +The Luma event is found from the tracker's LUMA URL field (the event page URL +that aaif-create-event's luma_push wrote back), or --event-id. The diff shows +every field that would change, live -> desired; --apply pushes exactly those +fields via events/update and re-verifies. Luma notifies guests of changes +unless --quiet (suppress_notifications) is passed. Cancellation is deliberately +NOT implemented here — it is irreversible and stays a manual Luma action. +""" +import argparse +import json +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[3] / "lib")) +from aaif_meetups import luma, office, tracker # noqa: E402 + + +def find_event_id(view, override): + if override: + return override + cell = (view["details"].get("LUMA URL") or "").strip() + if not cell: + sys.exit("ABORT: the tracker's LUMA URL field is empty — push the event first " + "(aaif-create-event) or pass --event-id.") + # No slug heuristic here: event pages may use aaif- slugs too, so let the + # entity lookup decide what the URL points at (only called when connected). + try: + return luma.resolve_event_id(cell) + except luma.NotAnEventUrl: + sys.exit("ABORT: LUMA URL doesn't point to an event page (%r) — likely the " + "chapter calendar link. Pass --event-id or the event's own URL." % cell) + except luma.LumaError as e: + sys.exit("ABORT: couldn't resolve the LUMA URL %r (%s). Retry, or pass " + "--event-id." % (cell, e)) + + +def fmt(v): + return json.dumps(v, ensure_ascii=False) if isinstance(v, (dict, list)) else repr(v) + + +def main(): + ap = argparse.ArgumentParser(description="Diff a tracker event against Luma; --apply to push") + ap.add_argument("docx", help="tracker.docx already downloaded via gws") + ap.add_argument("event", help="event title (case-insensitive substring), or 'next'/'latest'") + ap.add_argument("--timezone", required=True, + help="IANA timezone of the event city, e.g. America/Los_Angeles") + ap.add_argument("--duration-hours", type=float, default=3.0) + ap.add_argument("--event-id", help="evt- id override (else resolved from LUMA URL)") + ap.add_argument("--description-file", + help="markdown file to REPLACE the page copy (omit to leave it alone)") + ap.add_argument("--cover", help="new cover image to upload and set (omit to leave it alone)") + ap.add_argument("--quiet", action="store_true", + help="suppress Luma's guest notifications for this update") + ap.add_argument("--apply", action="store_true", + help="LIVE WRITE: push the diff to Luma (only after user approval)") + a = ap.parse_args() + + root = office.read_document(a.docx) + view = tracker.read_event(root, a.event) + description = open(a.description_file, encoding="utf-8").read() if a.description_file else None + desired = luma.event_payload(view, a.timezone, a.duration_hours, + description_md=description) + desired.pop("visibility", None) # never flip visibility from a sync + + if not luma.available(): + # Not connected -> can't diff or push; hand the user the manual checklist. + print("Luma is NOT connected (no API key for this calendar) — skipping the " + "automated sync. Ask the user to update the Luma page manually to:") + for k in sorted(desired): + print(" %-18s %s" % (k + ":", fmt(desired[k]))) + if a.cover: + print(" %-18s upload %s" % ("cover:", a.cover)) + print("(Or set up the key — see aaif-create-event's SKILL.md — and re-run.)") + return + + event_id = find_event_id(view, a.event_id) + live = luma.get_event(event_id) + changes = luma.diff_payload(live, desired) + if a.cover: + changes["cover_url"] = (live.get("cover_url"), "(upload %s)" % a.cover) + + print("Luma event: %s (%s)" % (live.get("url", "?"), event_id)) + if not changes: + print("No changes — Luma already matches the tracker.") + return + print("Proposed update (live -> desired):") + for k in sorted(changes): + have, want = changes[k] + print(" %-18s %s\n %18s -> %s" % (k + ":", fmt(have), "", fmt(want))) + if not a.apply: + print("\n[dry-run] Nothing sent. Review with the user; re-run with --apply " + "ONLY after they explicitly approve this diff. Guests %s notified." + % ("will NOT be" if a.quiet else "WILL be")) + return + + print("\nLIVE: updating the event on Luma%s..." + % (" (notifications suppressed)" if a.quiet else "")) + body = {"event_id": event_id} + if a.quiet: + body["suppress_notifications"] = True + for k, (_, want) in changes.items(): + body[k] = luma.upload_image(a.cover) if k == "cover_url" and a.cover else want + luma.update_event(body) + + # verify: re-fetch and re-diff. description_md is excluded — Luma's Spark + # round-trip isn't byte-stable, so it would always look dirty right after a + # push (cover_url likewise: the CDN rewrites the URL). + desired.pop("description_md", None) + still = luma.diff_payload(luma.get_event(event_id), desired) + if still: + print("VERIFY FAILED — fields still differ after update:") + for k, (have, want) in still.items(): + print(" %s: %s -> %s" % (k, fmt(have), fmt(want))) + sys.exit(1) + print("Done — re-fetched the event; it now matches the tracker.") + + +if __name__ == "__main__": + main() diff --git a/skills/aaif-update-event/scripts/test_luma_sync.py b/skills/aaif-update-event/scripts/test_luma_sync.py new file mode 100755 index 0000000..bcfbbae --- /dev/null +++ b/skills/aaif-update-event/scripts/test_luma_sync.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +import pathlib +import sys +import unittest +from unittest import mock + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +import luma_sync # noqa: E402 + + +def view(url): + return {"details": {"LUMA URL": url}} + + +class TestFindEventId(unittest.TestCase): + def test_override_wins_without_lookup(self): + with mock.patch.object(luma_sync.luma, "resolve_event_id") as r: + self.assertEqual(luma_sync.find_event_id(view("luma.com/x"), "evt-9"), "evt-9") + r.assert_not_called() + + def test_empty_cell_aborts_with_guidance(self): + with self.assertRaises(SystemExit): + luma_sync.find_event_id(view(""), None) + + def test_event_url_resolves(self): + with mock.patch.object(luma_sync.luma, "resolve_event_id", return_value="evt-1"): + self.assertEqual(luma_sync.find_event_id(view("https://luma.com/x"), None), + "evt-1") + + def test_calendar_link_aborts(self): + with mock.patch.object(luma_sync.luma, "resolve_event_id", + side_effect=luma_sync.luma.NotAnEventUrl("calendar")): + with self.assertRaises(SystemExit): + luma_sync.find_event_id(view("luma.com/aaif-sanfrancisco"), None) + + def test_lookup_failure_aborts_cleanly_not_traceback(self): + with mock.patch.object(luma_sync.luma, "resolve_event_id", + side_effect=luma_sync.luma.LumaError("HTTP 404")): + with self.assertRaises(SystemExit): + luma_sync.find_event_id(view("https://luma.com/gone"), None) + + +if __name__ == "__main__": + unittest.main()