A post is announced as Slack blocks rather than one line of text. The heading, the linked title and the post's own description sit in one section; the preview image follows as its own block, so Slack renders it full width rather than as a thumbnail; the byline moves to a context block, which Slack renders small and grey so it reads as attribution.
blog_feed_og() reads the description and image from the post
itself - Open Graph tags, falling back to Twitter's and to the plain
description, since the community's blogs are built by half a dozen
site generators. Measured across a sample of real post pages, about
three in four carry a description and an image. One extra request per
announced post, shared across workspaces, so a post's page is read
once however many channels announce it.
Image blocks always carry alt text. A post's own og:image:alt is
used when it is set, and becomes the image's caption too - that is the
one case where a human wrote something about this image. When it is
missing, which is most of the time, the alt says what the image is
("Preview image for ...") rather than inventing what it shows: the
image has not been looked at, and a confident fabricated description
would be worse than none. No caption is invented either.
Slack's own unfurl is turned off when we supply a preview, since it would append a second copy of the same image and description, and left on for a post that offers neither.
A preview Slack refuses costs the picture, not the post. Slack
fetches a block's image_url itself and rejects the whole message if
it cannot, so a rejection naming the blocks is retried once as plain
text. Without that, one malformed og:image would have cost the same
post on every run until someone fixed the blog.
slack_post_message() gained a blocks argument. text is still
sent alongside it, which is what a push notification and an
unsupported client show.
blog_feed_post() takes over the #blogs-by-rladies channels from
the Slack Feed app, in both the community and organiser workspaces. It
reads the curated list in
awesome-rladies-creations,
polls every entry that carries an rss_feed - blogs, YouTube channels
and websites alike, since they all serve the same RSS or Atom XML - and
posts one message per new item with its title, author and link. Adding
a blog to the curated list is now all it takes to get it into Slack:
nobody has to remember a matching /feed subscribe, and a blog removed
from the list stops being announced.
blog_feed_run() serves both workspaces from one poll of the
feeds, and bot-blog-feed.yml calls it about every ten minutes - the
cadence the Slack Feed app polled at. Polling once for the pair rather
than once per workspace halves the requests every contributor's blog
receives; each workspace is then filtered against its own seen-set, so
the two channels stay independent. One workspace failing does not stop
the other, and the run still goes red. GitHub runs schedules on a
best-effort basis, so this is "about every ten minutes" rather than a
guarantee.
Announced item ids are remembered in KV per workspace and recorded
after the posts succeed, including the ones that landed before a
mid-run failure - so a failure neither repeats what it already said nor
loses it. Items older than two weeks are never announced, which stops a
newly-added blog's back catalogue flooding the channel, and
seed = TRUE records the current feed items as announced without
posting - run once at cutover so the first real run doesn't repeat what
the Feed app already posted. Curated entries with no rss_feed are
named in the run log rather than silently skipped.
A post collected from two feeds is announced once. Two curated entries can resolve to the same post - a blog listed with both its main feed and a category feed - and the seen-set is only consulted at the start of a run.
A failed run is reported on GitHub only, not in Slack. Every other schedule pings the healthcheck channel when it fails, but this one runs 144 times a day, and a blog feed going quiet is not an incident worth interrupting a channel over - the Feed app it replaces said nothing either.
slack_post_message() gained an unfurl argument, off by default.
The blog feed turns it on: a message whose whole point is the linked
post should show the preview card.
guide_template() fetches a canonical communications template
from https://guide.rladies.org/templates/<name>.md, where the guide
now keeps the text RLadies+ sends to organisers and chapters. A failed
fetch is an error: there is no vendored fallback, because the second
copy is the drift this removes. A response that is not markdown is
refused too, so an error page never reaches a chapter.
guide_email_template() is the same thing for a template that is
sent as email. The guide writes those with their subject on a
leading SUBJECT: line, and this returns subject and body separately
so callers send the guide's subject rather than inventing one. It
errors when the template carries no subject.
prepare_inactivity_emails() now prepares the notice the guide
documents. jinx's own copy had drifted into warning organisers that
their chapter "may be deactivated", where the guide's documented first
notice offers help and only mentions retirement as what happens if
nobody replies. The subject line comes from the guide too. The
template_path argument is replaced by template, naming a guide
template, so the same function can prepare the retirement notice as
the second step of that process. It also stops reporting that it sent
the emails: it never has, and delivery goes through mail_send().
The Meetup setup brief quotes the guide's group description rather
than a copy of its own, which had lost a clause of the photography
consent paragraph. inst/templates/meetup-description.md and
inst/templates/chapter-inactive.md are deleted.
Placeholders are <<KEY>> everywhere. This is the convention the
guide uses, so one renderer serves both jinx's own templates and the
guide's. jinx's templates and translations are migrated; a key with no
value is left in place, which is what the guide wants for the parts a
human fills in.
The chapter setup and update checklists point at the guide for the "your chapter is set up" message, instead of at a wiki that no longer holds it.
gh_thank_contributor() now credits co-authors, reviewers and
commenters alongside the PR author. Co-authors come from the commit
authors and Co-authored-by: trailers (resolved via GitHub noreply
addresses), reviewers from submitted reviews, and commenters from both
the conversation and the review comments. Each person is named once,
under the most substantial thing they did, and bots are left out. A
login that does not match GitHub's login grammar is never mentioned,
because a trailer is text the PR author writes and @org/team in that
position would mass-mention a team.
gh_thank_issue_contributors() does the same when an issue closes,
which until now thanked nobody at all. It credits the reporter (warmer
wording if it was their first issue here) plus the assignees and
commenters. It fires on any close, since the reporting and triage
effort was real either way, but an issue closed as not_planned
(spam, duplicate, invalid) is thanked without the first-timer
congratulation. reusable-thank-contributor.yml now accepts
issues: [closed] alongside merged PRs.
An account GitHub reports as a Bot is never thanked, even when
its login carries no [bot] suffix. Copilot's review account is the
one that matters here: it posts as Copilot, so matching on the login
alone had jinx thanking a bot on most PRs. Logins are also compared
case-insensitively, so the author is not thanked alongside themselves
and nobody is credited twice under two spellings.
chapter_repo_audit() resolves every chapter's social_media$github
reference, and reports the dead ones with a likely replacement where
the conventional meetup-presentations_<city> repository exists. Two
shapes are accepted, because both are in use: owner/repo, and a bare
organisation name for the chapters that run their own GitHub
organisation. Over current data it finds 14 dead references out of
116, six of them with an obvious fix.chapter_status_reconcile() compares the website's status field
against the Meetup event history, and reports the two mismatches
worth acting on, on the guide's own windows: a chapter running events
that is not marked active, one with no event in six months, and one
with nothing for a year, which should simply be marked inactive.
inactive is now a status the chapter validator accepts. Chapters
are asked to post events to Meetup even when they advertise
elsewhere, so an absent event is a real gap rather than a sign the
chapter organises somewhere jinx cannot see. Only Meetup events are visible
to it, and the guide tells chapters they may organise elsewhere, so
the report says plainly that a quiet chapter is one to ask about
rather than one that has stopped.chapter_initiated_date() reads when a chapter first appeared in
the website repository, for the six-month window in which a chapter
with no events is unbegun rather than inactive. It follows renames,
without which the ten files renamed for the filename convention would
each look brand new, and returns NA for the 2023-01-04 bulk import,
whose date records the import rather than any chapter starting.chapter_validate_files() and chapter_validate_report() check the
chapter JSON a pull request touches, exposed to other repositories
through reusable-chapter-validate.yml. Only added and modified files
are checked, so the drift already sitting in the other files does not
block unrelated work. Errors fail the job; warnings are reported and
pass. Five checks run: the bundled schema, escaped byte placeholders
such as <e1> left in place of accented characters, the filename
matching the city/region/country it is derived from, urlname
agreeing with social_media.meetup, and a recognised status.chapter_create() derives its filename with chapter_filename()
and gained a region argument. It previously built the name from
country and city alone, so a chapter with a region got a filename the
rest of jinx would not look for - the second implementation of a rule
that only needs one.env_default() treats an empty environment variable as unset.
GitHub Actions passes an unset repository variable through as an empty
string, and Sys.getenv() only falls back to its default when a
variable is genuinely absent - so SLACK_PROMO_CHANNEL resolved to
"" in CI and the spotlight tried to post to #, failing with
channel_not_found. Applied to SLACK_PROMO_CHANNEL and
SLACK_DIGEST_CHANNEL, which had the same latent trap.copy_normalise() resolves a labelled Slack link to its label, not
its URL. Slack auto-links a bare domain, so a description written as
meetup.com comes back as <http://meetup.com|meetup.com>; resolving
that to the URL made it compare unequal to the text that was written,
on every pass, forever. The label is what the author typed, so that is
what the comparison should see.slack_api_call() gained encode = "form", and
slack_conversations_list() now uses it. conversations.list
ignores a JSON body outright - including limit and cursor - so
every page request returned the same first 100 channels with the same
cursor. The loop never terminated: on any workspace with more than 100
public channels it paged forever, in silence, using no CPU and raising
no error. Workspaces under 100 channels were unaffected, which is why
this went unnoticed.channel_index_load() and the welcome flow, not
just the channel copy pass.channel_copy_plan() now resolves a proposal through the rename
map when the channel no longer answers to its reviewed name.
Applying the renames made 25 of the community workspace's 43 channels
report as missing, because the copy is keyed by the old name - so a
later pass would have skipped them silently rather than maintaining
them. A channel that is absent for any other reason still reports
missing./slack/install?user_scope=rename requests a channels:write user
grant. Slack refuses conversations.rename from a bot token for a
channel the bot did not create, which failed all 25 community renames
with not_authorized; a grant from a workspace owner passes that
check. A routine install is unchanged and never asks for it.auth.revoke afterwards.channel_copy_apply(user_token =) uses it for name rows only.
Topics, descriptions and the channel join still go through the bot
token.inst/extdata/channel-copy.csv replaced every em-dash with a
hyphen, so 22 values were applied in a form nobody reviewed. The
package's ASCII convention governs R source, not Slack copy.copy_normalise() now undoes Slack's URL rewriting. Slack stores a
bare URL as <https://...>, so a description containing one compared
unequal on every pass and was reported as drift forever. Labelled links
(<url|label>) normalise to the URL too.channel_copy_plan() and channel_copy_apply() apply reviewed
topic, description and name changes to a workspace's public channels.
The reviewed copy ships as data (inst/extdata/channel-copy.csv and
channel-renames.csv) so it is diffable in review rather than buried
in code.unchanged when Slack already
holds the value, drift when the live value no longer matches what the
review recorded (so the proposal may be stale), missing when the
channel is absent, and apply otherwise. channel_copy_apply() is
dry_run = TRUE by default, sends only apply rows, takes a skip
list, and records per-row failures instead of aborting the pass./slack/install now requests channels:manage and
channels:write.topic alongside the existing scopes
(worker/src/slack-oauth.js). Slack grants exactly the scopes named in
the OAuth scope parameter, so without these an install could read
public channels but never set a channel's topic, set its description
(purpose), or rename it — regardless of what the Slack app config
allowed. Existing installs keep their old grant until the install flow
is re-run for that workspace.bot-channel-promo.yml, Mondays 15:00 UTC) calls
jinx::channel_promo_post(), which lists
the community's public channels, keeps those that have a description,
picks the next one round-robin (state stored in KV, so every channel
gets a turn before any repeats), and drafts a short invitation in Jinx's
voice via Workers AI — falling back to the channel's own description if
the model call fails. The generated blurb is escaped before posting, so
a channel's user-controlled description can't inject links or
<!channel> mass-pings into the broadcast. New: channel_promo_post(),
channel_promo_build(), channel_promo_format(),
promo_eligible_channels(), promo_pick_channel(), and promo_blurb()
in R/channel-promo.R. Configurable via SLACK_PROMO_CHANNEL (target,
default general) and SLACK_PROMO_SKIP (comma-separated names to never
feature)./jinx remind-me and /jinx pair are removed entirely — both duplicated
native Slack features (/remind and native group-DM creation) not worth a
bespoke command./jinx setup-channel, /jinx feedback, and /jinx questions now run
through the same slack-command dispatch pipeline as every other command,
instead of the Worker's LOCAL_COMMANDS fast path. New R:
setup_channel_process() (joins the channel and pins bookmarks from
inst/config/bookmarks.json, reading it directly instead of the Worker's
redundant GitHub-raw fetch) in R/welcome.R; question_feedback_summary()/
question_feedback_format() (aggregates the reaction_log:* KV counters
reaction_log_increment() writes) in R/question-log.R. questions
already had a working R handler - it was just unreachable from Slack.worker/src/slash-local.js now only handles /jinx shorten (organiser
workspace, low-latency link creation - the one command that still needs an
instant reply). worker/src/authorize.js shrank to just the
organiser-workspace check shorten still needs; the Airtable-backed
global-team lookup it used to do for feedback/questions is now handled
by cmd_authorize() on the R side, the same as every other gated command.cmd_attach_slack_context() merges team_id/channel_id/channel_name
onto a parsed command for the two new Slack-context-aware commands, mirroring
how event_parse() already owns shaping the slack-event payload - so
bot-commands.yml stays thin, untested plumbing rather than the place this
logic lives. It also now sets both SLACK_ORGANISER_TOKEN/
SLACK_COMMUNITY_TOKEN and SLACK_ORGANIZER_TEAM_ID/
SLACK_COMMUNITY_TEAM_ID so setup-channel can run from either workspace.jinx::question_digest_post() and
jinx::question_log_purge() directly, not Cloudflare Cron Triggers
running worker/src/question-digest.js (deleted) and
question_log_purge() in worker/src/question-log.js (removed). Both
were cron-triggered batch jobs with no live caller waiting on a
response, so there was nothing requiring them to run at the edge — this
removes a JS/R duplicate-implementation risk in favour of a single R
implementation. New: cloudflare_generate() (Workers AI chat
completion, sibling of cloudflare_embed()), question_log_purge(),
and six question_digest_*/question_content_gaps() functions in
R/question-digest.R. The worker-local /jinx questions//jinx feedback Slack commands stay JS — moving those to the GitHub Actions
dispatch path would trade their current sub-second response for a
measured ~2–2.5 minute round-trip, an unacceptable regression for an
interactive command.airtable_webhook
and slack_interaction slack-event kinds added to jinx_events().
New R/airtable-invite.R: airtable_record_update() (R's first
Airtable write primitive - everything in R/airtable-sync.R is
read-only), airtable_base_allowed() (KV-cached Meta API scope check),
slack_invite_request_blocks()/slack_invite_approval_checklist_blocks()
(Block Kit builders), airtable_webhook_process(), and
slack_interaction_process()./airtable/webhook, and Slack signature/team-allowlist
verification plus an immediate "Processing…" placeholder ack on
/slack/interact (worker/src/airtable-invite.js shrank from 344 to
~135 lines). The two-step message replacement (JS ack → R final state)
means an approval card can take up to a couple of minutes to reach its
final state - acceptable for an admin-only approval flow with no
user-facing latency requirement.airtable_record_update()'s URL is now built with
httr2::req_url_path_append() instead of unescaped glue::glue()
interpolation, restoring the encodeURIComponent() escaping the
deleted JS had; slack_interaction_mark_sent()'s pending-link KV write
now has its own error handling (matching the deleted JS), so a KV
failure no longer gets misreported as an Airtable failure when the
actual Airtable update succeeded.slack-event repository_dispatch type carries passive Slack
events (team_join, reaction_added, and — once the Airtable invite
flow migrates — airtable_webhook/slack_interaction) from the
Cloudflare Worker to a new bot-events.yml workflow, mirroring the
existing slack-command dispatch that already relays slash commands.
New R/event-registry.R (event_parse(), event_authorize(),
event_execute()) is the event-side companion to
command-registry.R's command pipeline.R/welcome.R (welcome_send(), welcome_message_render(),
channel lookup/mention, DM-open, pending-link consume) reads the
welcome config and templates directly from this package's inst/
instead of the Worker's redundant GitHub-raw fetch of the same files.
The two welcome templates' placeholder syntax changed from
{{lowercase}} to <UPPERCASE> to match this package's existing
render_template() convention. New question_vote_apply() and
reaction_log_increment() in R/question-log.R replace the
equivalent JS functions (deleted from worker/src/slack-events.js
and worker/src/question-log.js).R/slack-manage.R
(slack_api_call(), slack_bot_token(), slack_workspace_for_team(),
slack_response_url_post()) and KV write primitives in R/cf-ops.R
(cf_ops_kv_put(), cf_ops_kv_delete()) underpin the above and are
reused by the still-to-come Airtable invite-flow migration.SLACK_COMMUNITY_TOKEN GitHub secret (alongside the
existing SLACK_ORGANISER_TOKEN) so welcome DMs work in the community
workspace, not just the organiser one..catch() behaviour: a KV cleanup failure in
pending_link_consume() no longer blocks sending the welcome DM, and a
reaction-tally write failure in reaction_event_apply() no longer
blocks applying the actual 👍/👎 vote (or vice versa). bot-events.yml
also gained a notify-failure job (posting to the existing Slack
healthcheck channel via reusable-slack-fail-notify.yml) so a failed
event run is visible instead of silent, and its Slack-metadata masking
no longer attempts (incorrectly) to mask the multi-line nested event
JSON payload — only the single-line team_id/response_url fields,
which is all ::add-mask:: can actually mask.l.rladies.org, backed by
worker/src/short-links.js and a SHORT_LINKS KV namespace. Create a link
via /jinx shorten <url> [slug] in Slack (organisers workspace only — an
open shortener reachable from the community workspace would be an
open-redirect/phishing risk) or POST /links/shorten on the HTTP API for
other repos. Re-shortening a URL that already has a short link returns the
existing one instead of minting a duplicate, even when a conflicting custom
slug is requested.POST /ai/generate, authenticated
with a shared JINX_API_KEY bearer token (constant-time compared,
worker/src/api-auth.js), so other RLadies+ repos can get Workers-AI text
generation without holding their own Cloudflare credentials. It's a thin,
model-agnostic passthrough to the existing AI binding (model must be on a
Worker-side allowlist). First consumer: rladies/quarto-rladies-report,
for AI-drafted report prose. See the README's "HTTP API for other repos"
section. (Web Analytics doesn't need an HTTP route — rum_collect_analytics()
is already exported directly from this package for a calling repo to add
as an R dependency instead.)/jinx workers-status reports Workers invocation/error/CPU metrics for
the jinx script, via cloudflarer::cf_workers_invocations(). jinx_safe
— read-only, same class as gha-dashboard./jinx cache-purge <prefix> [<prefix> ...] purges specific URL prefixes
from the Cloudflare cache, via cloudflarer::cf_purge_cache().
jinx_gated — the one genuinely mutating, production-affecting command in
this integration. The R wrapper (cf_ops_purge_cache()) deliberately does
not expose purge_everything; a maintainer needing a full-zone wipe calls
cloudflarer::cf_purge_cache(purge_everything = TRUE) directly. The
command handler echoes back exactly what was purged, and prefixes are
sanity-checked to look like domains before reaching the mutating call.cf_ops_list_kv_keys()/cf_ops_get_kv_value() are exported for
console-only KV inspection during an incident — deliberately not wired
into a /jinx command, since the SLACK_TOKENS KV namespace holds live
Slack OAuth tokens and a chat-triggered arbitrary-namespace read would be a
real exfiltration path.cf_ops_*() functions read CLOUDFLARE_OPS_API_TOKEN first, falling
back to CLOUDFLARE_API_TOKEN if unset./jinx cf-analytics [days] reports on Cloudflare's Web Analytics (RUM)
beacon data, a new sibling to the existing Plausible-backed
/jinx website-analytics. rum_collect_analytics()/rum_format_analytics()/
rum_generate_report() in R/website-rum.R query pageviews and top
pages/referrers/countries via cloudflarer::cf_rum_page_views() and
cf_rum_top(). jinx_safe — aggregate traffic data, same sensitivity class
as the Plausible command. No issue-publishing helper; like gha-dashboard,
the report is returned directly as the command reply./jinx questions [days] now works from GitHub too, not just Slack.
question_log_query() reads the same jinx-question-log D1 database
worker/src/question-log.js writes to, via
cloudflarer's
cf_d1_query(). question_gaps_rank() and question_downvoted_rank() are R
ports of the worker's ranking logic, folding near-duplicate unanswered
questions together and surfacing net-downvoted answers, worst first.
question_log_format() renders the outcome breakdown, top content gaps, and
most-downvoted answers as markdown. Like its Slack counterpart, the command
is restricted to the global team — the corpus is anonymous, but still
internal usage insight.httr2 request builder. cloudflare_account_id() now delegates
to cloudflarer::cf_list_accounts(); cloudflare_embed() and
cloudflare_vectorize_upsert() stay custom (cloudflarer doesn't wrap Workers
AI inference or Vectorize v2) but now build on cloudflarer::cf_request()
and unwrap responses with cloudflarer::cf_resp(), which raises a classed
cloudflarer_error on API failure instead of a bare HTTP-status error.
cloudflare_vectorize_upsert()'s return value changed from the full
{success, errors, result} envelope to just the unwrapped result payload.Jinx now keeps an anonymous question-improvement log so the corpus can
evolve. Every @Jinx mention or DM records the question text and a coarse
outcome (answered, no_match, coding_declined, or low_confidence) to a
Cloudflare D1 table — with no Slack user id, channel, or thread timestamp, so
a logged question cannot be traced to who asked. Answers are linked so a 👍/👎
reaction updates that question's score, turning "which answers were weak" into
a maintainer to-do list. /jinx questions [days] surfaces the top gaps and
most-downvoted answers; rows are purged after 180 days by a daily cron. Reading
the log (via /jinx questions and /jinx feedback) is restricted to the
global team, reusing the same Airtable member directory as cmd_authorize().
Requires provisioning a jinx-question-log D1 database (see wrangler.jsonc).
A weekly digest turns those gaps into an action list. Every Monday a
scheduled job reads the question log, clusters the questions Jinx couldn't
answer well (no_match / low_confidence, excluding declined coding
questions), drafts a proposed Guide answer for the top gaps, and posts the
lot — plus any 👎'd answers — to the global-team Slack channel
(SLACK_DIGEST_CHANNEL, default #team-jinx) for review. The corpus stays
human-authored: drafts are AI-suggested and unverified, only ever a Slack
message for a person to confirm before editing the Guide — nothing is written
to the RAG index automatically.
Global-team authorization matches on the Slack member id, not the handle.
cmd_authorize() (and the worker's command gate) now compare the requesting
Slack user_id against an organiser_slack_id column in the member directory,
rather than the informal @mention in organiser_slack. Handle matching was
unreliable (nicknames, real names with spaces) and impossible under Enterprise
Grid, where users.info cannot resolve profiles. bot-commands.yml now passes
user_id as the actor for Slack-sourced commands. Populate organiser_slack_id
for each global-team member.
directory_purge_submissions() erases a member's Airtable submissions for
GDPR right-to-erasure. Given the directory slugs a purge removed, it deletes
every submissions row resolving to those slugs (by directory_id, falling
back to identifier), so no submitted PII lingers in Airtable and a later
sync cannot re-create the entry from a leftover row. Unlike
directory_mark_synced() (routine cleanup, which only flags synced), this
destroys the rows. The reviewed purge workflow calls it after force-pushing
the scrubbed history.
directory_mark_synced() replaces the artifact-based Airtable cleanup.
Called after a sync PR merges (and after a purge completes), it flags handled
submissions with a synced checkbox so directory_sync_airtable() skips them
on the next run: an update is flagged once its entry is fully incorporated in
main, and a delete request once its entry file is gone. It reconciles
statelessly — re-deriving incorporation from main using the same test the
sync uses to decide whether to commit — so it needs no state passed between
workflows and self-heals submissions already incorporated. This retires the
old flow, which uploaded Airtable record ids as a build artifact for a
merge-triggered workflow to download and delete; that flow broke silently
when the sync moved into jinx (the artifact was no longer produced).
Requires a synced checkbox field on the submissions table.
The automated review lists clickable profile links instead of probing
them. validate_directory_pr() used to HTTP-check whether each social
handle resolved, but the platforms block bot requests, so nearly every
entry was flagged ("may not resolve") — noise that trained reviewers to
ignore the report. It now renders each entry's socials as ready-made links
the reviewer clicks to confirm. Also fixes the Mastodon URL builder, which
returned NA for the normal @user@instance form.
directory_sync_airtable() now produces real directory entries. It reads
the live submissions base (appzYxePUruG9Nwyg, table submissions) with its
linked languages, countries, and interests tables, and writes the full
entry schema (data/json/<slug>.json), profile photos (data/img/), and
contact emails (contact/<slug>.json). Returning submitters are matched to
their existing entry by slug (directory_id, falling back to identifier)
and merged as a partial update — clear_fields are wiped first, then
submitted fields overlay the rest. Only genuinely changed files are committed
(order- and formatting-insensitive comparison), and delete requests are
reported in the PR body rather than executed. Replaces the earlier stub that
targeted a placeholder base and wrote a name-plus-socials shape.
The directory sync runs from the private rladies/directory repo, not
jinx. Directory submissions carry confidential data (contact emails), and
jinx is public, so the sync and its logs must stay in the private repo, which
installs jinx and calls directory_sync_airtable(). With the global-team
sync also gone, the public ops-airtable-sync.yml workflow is removed.
validate_directory_pr() is now a real automated review, posting one
consolidated comment on directory PRs covering filenames, likely-duplicate
slugs, contact-method vs. social-entry consistency, stray contact info in
free text, and whether social handles resolve — run from the private repo's
review workflow. Handle normalisation now also covers github and bluesky.
The bundled directory-entry.json schema now matches the full entry shape
(location, social media, interests, languages, activities, work, photo).
PLAUSIBLE_* environment
variables have been dropped. website_collect_analytics() now takes a
from/to date window and reads CLOUDFLARE_API_TOKEN /
CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_SITE_TAG; the Cloudflare Web Analytics
query is not yet implemented and currently aborts with setup guidance. The
markdown/Slack formatting helpers are unchanged.copilot_sync_repo() fetches the four
rladies/grimoire review skills
(brand, blog, social, translation) plus the foundation brand ruleset and
lands them in a target repo as .github/copilot-instructions.md and
path-scoped .github/instructions/*.instructions.md via a pull request —
the bridge that makes Copilot's code review grimoire-aware. Run it with
/jinx copilot-sync <owner/repo>./jinx review brand|blog|social|translation <pr> (accepting #42, owner/repo#42, or
a PR URL, plus brand-check/blog-review/social-review/translate-review
aliases) requests a Copilot review on a PR and posts a scoping comment via
copilot_review_pr(). The new reusable-copilot-review.yml workflow does
the same automatically when a content PR opens. Both commands are gated to
the global team.cmd_authorize() gates every command before execution: read-only
actions (help, reports, analytics, dashboards, lookups) stay open,
while any mutating command (invite, offboard, chapter/CFP/poll
creation, syncs, …) requires the requester to appear in the global
team member directory in Airtable. GitHub commands are matched on the
commenter's login; Slack slash commands are matched on the signed
user_name. The check fails closed — unknown actors and directory
lookup failures are both denied — closing a path where any member of
an allowlisted Slack workspace could trigger privileged GitHub
actions. New commands are privileged by default until explicitly
declared safe.user_name is mutable and workspace-scoped, and
the community workspace is openly joinable, so a colliding handle there
must not authorize privileged actions. cmd_authorize() only honors
privileged Slack commands originating from the organisers workspace;
read-only commands are unaffected.jinx_safe or
jinx_gated next to its handler; dispatch and the safe/gated
classification both derive from it, and a test asserts every command
carries a keyword and every parseable action is registered. Replaces
the separate hand-maintained safe-command list, so a new command is
labelled where it is defined./jinx chapter-health now reports real data. The command was a
placeholder that echoed "Checking chapter health..."; it now runs the
health check and summarises inactive chapters./jinx blog-add <url> now opens a PR. New blog_add_pr() fetches
the page metadata and opens a PR adding the entry to
awesome-rladies-creations (skipping domains already listed); the
command previously only echoed the URL./jinx blog-check-links now checks real links. New
blog_check_links_repo() reads the community blog entries from
awesome-rladies-creations and reports broken URLs/feeds; the command
previously only echoed "Checking blog links...".blog_create_entry() now derives the filename correctly. The
domain was extracted by stripping from the first /, which is inside
https://, so every entry collapsed to https.json. Protocol is now
stripped before the path./jinx validate-directory. Directory entries are already
validated on every push and PR by the directory repo's own
validate_jsons.yml; the jinx command duplicated it and did nothing.slack_subscribe_rss() posts an actionable request. It previously
sent /feed subscribe <url> as a bot message, which Slack never
executes, so nothing was ever subscribed. It now posts a request for a
human in the channel to run the command.gt_finalize_onboarding() no longer logs "Granted access" for repo
access it never granted. Repo access is conveyed by team membership;
the log now states that accurately.gt_sync_airtable(). It fetched global-team data and
discarded it, duplicating the complete sync that already runs weekly in
the website repo (scripts/get_global_team.R). Global-team sync is owned
there. With the directory sync also moved out of jinx (see Directory), the
ops-airtable-sync.yml workflow is removed entirely.meeting_poll_create() opens a "find a time" poll (public by default,
returning a host edit_token), meeting_poll_get() /
meeting_poll_best() read responses and ranked slots,
meeting_poll_lock() locks the chosen slot, and meeting_poll_ics()
exports it as an .ics file. Exposed in chat via /jinx poll create …
and /jinx poll best <id>. Base URL is overridable through
SAMKOMA_BASE_URL. Externally-sourced poll titles and participant
names are markdown-neutralised before being rendered into bot-authored
GitHub/Slack messages, so they cannot inject links or formatting.gt_finalize_onboarding() now calls gt_schedule_onboarding_meeting(),
which opens a samkoma poll spanning a two-week window of candidate dates
starting one week out and posts the poll link to the onboarding issue. A
samkoma outage warns and is skipped rather than aborting onboarding.is_first_time_contributor()
was passing creator= to GET /repos/{owner}/{repo}/pulls, which the API
silently ignores — every author looked like a first-timer until a repo had
more than one PR total, and like a returning contributor after that. Now
uses GET /search/issues with is:pr/is:issue + author: to count
authored items correctly.slack_event_strip_mention()
now removes <@U…|display> user mentions, <!subteam^S…|name> group
mentions, and <!channel|here|everyone> broadcasts in addition to the
bare <@U…> form. Previously the bot's own past mentions could leak into
thread history as user turns.bot_user_id. If the
team KV record has no cached bot_user_id (older installs),
slack_thread_history() now drops the whole history rather than
relabelling every prior bot reply as role: "user".slack_api_call() honours
Retry-After (capped at 5s) before raising, so a single rate-limited
post no longer aborts the answer path with the failure quip.event_id. slack_event_handle_reaction()
now skips duplicate Slack retries of the same reaction_added event so
the daily counters stop double-incrementing.ops-chapter-onboarding,
ops-global-team-invite, ops-global-team-finalize,
ops-global-team-offboard, ops-update-contributors, and
ops-airtable-sync now move every ${{ inputs.* }} into an env:
block and read it from Sys.getenv() inside Rscript. The invite
workflow also builds its JSON artifact with jq instead of echo so
a ' in a name no longer crashes the run.actions/checkout steps now set
persist-credentials: false, all ops/CI/infra/reusable workflows get
timeout-minutes: 15, and every self-racing ops workflow gets a
concurrency: group key (the directory sync, contributors update,
per-username invite/offboard, etc.).gh_branch_upsert(force = FALSE) returns the real head. Previously
it returned the base SHA when the branch already existed, which was
wrong for any caller that wanted to act on the branch tip.
Now reads the existing branch's head SHA instead.airtable_list_records() now wraps each
paginated request in req_retry(max_tries = 3), so a 429 mid-sync no
longer aborts the whole pull.maria.json. airtable_to_directory_entry() now appends a
6-char hash of the Airtable record id to the slug._Last updated: {Sys.Date()}_, so it always diffed and committed
daily even when no contributors had changed. The check now strips that
line before comparing.is_team_member fails open on transient errors. A real 404 still
returns FALSE, but a network blip or 5xx returns TRUE (with a
warning) so we don't spam the global team with first-timer welcomes
during a GitHub hiccup.review_assign_onboarding surfaces errors. Comment-post failures
now emit a cli_alert_warning instead of being swallowed silently,
so a failed cc @rladies/... tag is visible in the run log.conversations.replies before calling the LLM, so follow-up
questions no longer require restating the whole conversation. Up to
the last 8 turns (capped at ~4k chars) are passed in as
user/assistant history. Applies to both assistant DM threads and
channel app_mention threads. The user's two most recent prompts
are also folded into the retrieval embedding so the right sources
surface when the new question is a one-word follow-up.directory_create_pr() was a stub that returned the directory's
pulls-page URL without creating a branch, writing files, or opening
a PR. The function now creates a dated jinx/airtable-sync-YYYYMMDD
branch, commits each changed contact/{slug}.json file via the
contents API, and opens a PR back into main (returning the URL of
an existing open PR if the branch was already in flight).write_directory_entries() now returns a list of {filename, path, content, sha} records for changed entries instead of only their
filenames, so the PR step can actually write them.rladies/global-team. The publish helpers
(report_publish(), analytics_publish_dashboard(),
gha_publish_dashboard(), event_publish_summary()) and the
matching scheduled workflows have been removed.chapter_report_health() now returns the formatted markdown body
instead of opening an issue.ops-event-sync.yml keeps the meetup sync but no longer publishes a
summary issue.contributor_update() now commits the rendered contributors file
straight to the default branch (main by default) and returns the
commit URL. The previous behaviour of opening a jinx/update-contributors
branch and PR has been removed. The ops-update-contributors workflow
now declares contents: write.awesome-rladies-creations packages feed has 41 entries where
pkdown_url and/or repo_url parse as empty JSON objects ({})
rather than strings or null. The scheduled indexer crashed mid-run
on 2026-05-24 with missing value where TRUE/FALSE needed when one
of these reached nzchar(). The custom %or% operator and the
downstream nzchar() checks now go through a new is_blank()
helper that treats NULL, zero-length vectors and lists, NA, and the
empty string uniformly as "missing". Regression tests pin the
empty-list and NA cases.parse_unix_date() no longer errors when handed an empty list or
any other non-character / non-numeric value; it returns NULL,
matching its behaviour for NULL and "".gather_events_json()
now also builds a single events-digest chunk (events_digest_chunk())
listing every upcoming event globally, soonest first, with a per-event
link and venue. The worker pins this digest into context for
event-intent questions so it always reaches the model, boosts its
source weight, and allows the digest's embedded per-event links to be
cited. gather_all_chunks() now honours a chunk's own source_type,
falling back to the source default.parse_unix_date() no longer crashes the indexer on a malformed
date string. as.POSIXct() with the default format raises an error
(not a warning) on an unparseable string, which suppressWarnings()
does not catch; a single bad date in the feed would abort the whole
index build. Parse failures now degrade to NULL.indexer/ directory
into the R package. All 8 sources (hugo-site, github-org,
pkgdown-llms, github-files, github-remote-files, events-json,
awesome-creations, youtube-channel) are now configured in
inst/config/rag-sources.yml and implemented as
gather_<type>() functions under R/rag-source-*.R. Adding a new
source is one new R file plus a YAML entry — see the
RAG indexer article.sha256("{repo}|{path}|{chunk_idx}")[1:32], so
the R re-index updates the existing rladies-content index in place
rather than orphaning vectors.rvest + rmarkdown::pandoc_convert()
(html → gfm-raw_html) instead of cheerio + turndown. Pandoc emits
proper GFM pipe tables where turndown produced flat key/value text;
other output differs only cosmetically (- vs * bullets).bot-index-content.yml now uses r-lib/actions/setup-r and calls
jinx::rag_index_build().httr2::req_perform_parallel
(max_active = 8) to match the throughput of the JS pool the
indexer replaced.gather_rag_source() now hard-errors on an unknown source type so a
YAML typo in inst/config/rag-sources.yml aborts the run rather
than silently skipping a source on the weekly cron.req_retry(max_tries = 3) policy via the base cloudflare_request()
helper, so a transient 5xx no longer kills the whole indexer.extract_hugo_page() that could embed the
literal string "NA" into chunk text when a Hugo page was missing a
<title> or <meta name="description"> tag.events chunks whose
date is in the future, so the handful of upcoming events in the
index float to the top of the top-5 instead of being drowned out by
the much larger pool of past events kept on the 365-day trailing
window.events
chunks into the candidate pool before reranking. This rescues
upcoming events whose cosine similarity to the user's casual
phrasing would otherwise have left them outside the top-20. The
fixed event-shaped prompt's embedding is memoised in KV under
rag:event_embedding:v1 so the second retrieval only costs one
extra vector query (no second embed call) after the first warm-up.Status: upcoming when the
user asks about future events, and own it honestly if no upcoming
events are in the retrieved sources rather than substituting a past
one./jinx slash commands from the community workspace previously
failed with channel_not_found because the GHA workflow used the
organisers' bot token to post into a community channel ID. The
worker now forwards team_id in the dispatch payload, the workflow
verifies it against SLACK_ORGANIZER_TEAM_ID /
SLACK_COMMUNITY_TEAM_ID repo vars, and the reply step posts via
the workspace-agnostic response_url instead of chat.postMessage.gh_welcome_contributor() and gh_greet_contributor() gained an
extra_message argument. The string is appended after the standard
welcome and before the jinx signature, so callers can add a
project-specific reminder (e.g. "remember to add yourself to
.zenodo.json") without forking the function.reusable-welcome-contributor.yml now accepts a matching
extra_message workflow input and forwards it via an environment
variable (no string interpolation into the R source, so the input is
safe to set to any markdown).pull_request_target opens, so callers
that need to welcome fork-PR authors can switch their trigger
without losing the welcome step.gh_* module for reusable GitHub PR/issue automation. Functions
moved out of the contributor/website modules so they can be wired into
any repo, not just the website:
contributor_welcome() → gh_welcome_contributor()contributor_thank() → gh_thank_contributor()contributor_greet() → gh_greet_contributor()blog_post_checklist() → gh_post_checklist() (also generalised:
the message now opens with "Thank you for submitting a post" so it
applies to blog and news content equally; the path filter on the
caller workflow already covers both).chapter_get_language() → i18n_get_chapter_language() (it has
always lived in R/i18n.R, now follows the <module>_<verb>_*
pattern).directory_validation_row() and
directory_empty_validation_df() in R/i18n-validate.R renamed to
i18n_validation_row() / i18n_empty_validation_df() (they validate
translations, not directory entries).reusable-website-blog-checklist.yml →
reusable-post-checklist.yml. reusable-welcome-contributor.yml
and reusable-thank-contributor.yml updated to call the new names.Initial release. jinx automates organisational workflows for the RLadies+ GitHub org: chapter onboarding and health, directory validation, blog and announcement publishing, PR review, Airtable sync, Slack, analytics, events, CFP coordination, and i18n.
Exported functions follow a <module>_<verb>[_<object>] schema so they
group cleanly by autocomplete. Short prefixes are used for established
acronyms (cmd_, gt_, li_, cfp_, gha_, i18n_).
Triggered via /jinx issue comments (cmd_parse() / cmd_execute()) and
24 GitHub Actions workflows. Ships with a pkgdown site, Getting Started
and Workflow Reference vignettes, and starter translations for Spanish,
Portuguese, and French.