CLI binaries¶
The image (and a source build) ships eight binaries. notifycat-server is the long-running process; the rest are operator tools you run ad hoc.
| Binary | Purpose |
|---|---|
notifycat-server |
The HTTP server — webhooks in, Slack out |
notifycat-config |
Parse and validate config.yaml against Slack and the git host |
notifycat-doctor |
Preflight diagnostics: config, database, mappings, per-repository probes |
notifycat-smoke |
Forge a signed PR event end-to-end to prove Slack delivery |
notifycat-migrate |
Apply or inspect the embedded SQLite migrations |
notifycat-reconcile |
One-time backfill: mark long-closed PRs closed in the database |
notifycat-relocate |
Move open PRs' Slack messages to another channel after repointing a repo |
notifycat-keygen |
Generate the admin key and the bcrypt hash the server stores |
Under Docker, pass the binary name as the command; the Compose install wraps the common ones (./notifycat doctor, ./notifycat smoke):
docker compose run --rm notifycat notifycat-config validate
docker run --rm --user $(id -u):$(id -g) -v "$PWD:/app" --env-file .env \
ghcr.io/mptooling/notifycat:latest notifycat-doctor
notifycat-config¶
notifycat-config list # print the parsed config — no network
notifycat-config validate # validate entries, cache-aware
notifycat-config validate owner/repo # validate one entry, ignoring its cache
notifycat-config validate --force # revalidate everything, ignore the lock
list prints a tab-aligned table of every parsed entry — the quick sanity check after an edit. A tier with a channels: list or path channels shows every channel it can post to, comma-separated. validate runs the real checks, one line per check in greppable OK/WARN/FAIL/SKIP form. Only FAIL sets exit code 1; WARN reports an actionable problem in external state and still exits 0:
| Check | Verifies | Remediation |
|---|---|---|
mapping |
An entry covers owner/repo (explicit tier or "*"). |
Add a tier — see routing. |
channel-format |
The channel ID matches [CGD][A-Z0-9]{2,}. |
Use the Slack channel ID, not the display name. |
slack-auth |
auth.test passes and the token carries chat:write + reactions:write. |
Rotate SLACK_BOT_TOKEN or reinstall the app with the right scopes. |
slack-channel |
The channel exists, isn't archived, and the bot is a member. | /invite @notifycat; fix the ID; unarchive. |
webhook |
With a read token set: an active webhook targets /webhook/<provider> and subscribes to the PR events Notifycat needs. Skipped without a token. |
WARN, never FAIL: create it — GitHub / Bitbucket — or add the missing events. A 403 on the hooks listing means the token's identity lacks write/admin on the repository. |
org-repos |
For an org with a "*" tier and a read token set: lists the org's repositories and validates each one. Skipped without a token. |
WARN, never FAIL: usually a token-scope problem — grant the token read access to the org's repositories. |
Successful results are hashed into the sibling config.lock, so steady-state boots and re-runs only revalidate what changed. Entries that warned are left out of the lock — including on validate owner/repo — so they are re-probed until they pass. Commit the lock next to config.yaml in the repository that owns your deployment; the operator workflow is edit → validate → commit both → deploy. The server runs this same validation at boot and refuses to start on a FAIL; a WARN is logged and tolerated (operations).
notifycat-doctor¶
Preflight for everything validate doesn't cover: secrets present, TTL sane, database reachable, mappings file parseable — plus the per-repository Slack/webhook probes when you name a repository. Safe to run in production; read-only apart from opening the database. It has its own page.
notifycat-doctor # config + database + mappings
notifycat-doctor owner/repo # + Slack and webhook probes for one repository
notifycat-smoke¶
The last step of an install and the first step of a delivery investigation: it forges a correctly-signed opened event for a mapped repository, POSTs it to the running server, and reports the channel and message timestamp Slack returned. Real signature check, real dispatcher, real Slack call.
./notifycat smoke acme/api # posts a throwaway "[notifycat smoke] …" message
./notifycat smoke --reactions acme/api # + replays comment, approval, merge; verifies the emoji landed
A secret mismatch surfaces as a clear 401; an unmapped repository is rejected before anything is sent. --reactions verification needs the optional reactions:read scope — without it the reactions still post, and the check reports "could not verify" instead of failing. Delete the throwaway message when you're done.
With the agent API on, the smoke PR is also recorded for the agent feed. Smoke removes it when it finishes. An agent that polls during the few seconds of the run can still receive it.
notifycat-migrate¶
The server applies migrations itself at startup, so most deployments never run this. Use it when you want migrations as a separate, controlled step:
notifycat-migrate up # apply pending migrations
notifycat-migrate status # show migration state
notifycat-reconcile¶
One-time repair after enabling the digest on an older deployment: walks every row the database believes is open, asks the git host, and marks the merged/closed ones. Idempotent; needs a read token; a PR it can't read is left alone. Usage and the summary format: digest → first run.
notifycat-relocate¶
Changing a repository's channel: only redirects new PRs. The messages of PRs that were already open stay where they were posted — the database records the channel each one lives in — so reviews and merges keep updating the old channel until those PRs close. This tool carries them over.
notifycat-relocate -audit # what sits in channels config no longer mentions
notifycat-relocate -from C0OLD -to C0NEW -dry-run # preview
notifycat-relocate -from C0OLD -to C0NEW # move them
notifycat-relocate -from C0OLD -to C0NEW -repo acme/api
notifycat-relocate -from C0OLD # stop tracking them, leave them in place
Start with -audit: it lists every stored message whose channel is absent from the repository's current configuration, which is exactly the set that needs moving. It cannot tell you where each should go — a paths:-routed PR legitimately has a message in only one of several possible channels — so the destination stays yours to name.
Nothing is ever deleted. The original message stays in the old channel, edited down to a single struck-through line pointing at its new home:
:truck: [moved to #new-channel] ~PR #7: Add widgets~
so any thread hanging off it stays readable where it is. The pointer keeps no context line and no Start review button — the button would decorate a message that is no longer the PR's.
Per PR, one of three things happens:
- Moved — the message is reposted in the destination, the stored row is retargeted, the repo's own reactions are carried over, and the original becomes a pointer.
- Merged — the PR already has a message in the destination (a
channels:overlap), so it keeps that one; the original becomes a pointer and its row is dropped. No duplicate is ever posted. - Forgotten — no
-togiven, or the original is already gone from Slack: the row is dropped and the message is left exactly as it is.
The reposted message announces itself: :truck: [moved from another channel] please review …. It pings nobody — you changed a routing target, which isn't news worth a notification, and the original mentions belonged to the old channel. Everything else survives: the context line, accumulated "reviewing" / "reviewed by" markers, and a working Start review button. Reactions come across too, filtered to the emoji the repo's own notifications use, since the bot can't re-add a human's reaction as that human.
Runs are idempotent: the row is retargeted immediately after the repost, so a re-run skips whatever already moved, and it can never leave a row pointing at a message that was never posted. If the pointer edit is the step that fails, the move is already recorded — the run reports the error, and the only trace is an un-marked message in the old channel.
SLACK_BOT_TOKEN needs two scopes beyond the server's own — reactions:read and the history scopes channels:history / groups:history — because the tool reads a posted message back. The run refuses to start without them, and refuses a destination the bot isn't a member of, rather than failing partway. The server needs the history scopes only for entries with cleanup.delete_on_close on.
notifycat-keygen¶
Generates the admin key and the hash the server checks it against. The key goes to stdout; the NOTIFYCAT_ADMIN_KEY_HASH='...' line goes to stderr. Split the two streams to store each one:
# only you can read key.txt and hash.txt
umask 077
# pull first, so pull progress does not end up in hash.txt
docker pull ghcr.io/mptooling/notifycat
# the key stays on screen, the hash line goes to hash.txt
docker run --rm ghcr.io/mptooling/notifycat notifycat-keygen 2>hash.txt
# or both go to files
docker run --rm ghcr.io/mptooling/notifycat notifycat-keygen >key.txt 2>hash.txt
# from a source checkout instead of the image
go build -o bin/notifycat-keygen ./cmd/notifycat-keygen
./bin/notifycat-keygen >key.txt 2>hash.txt
Keep the key in a password manager. The hash line is a secret too: put it in your platform's secret store (Fly: fly secrets import < hash.txt; Kubernetes: a Secret; Compose or the plain binary: the gitignored .env). Never commit it to git, not even to a deploy repository. Then delete key.txt and hash.txt.
Keep the single quotes around the hash. A bcrypt hash contains $, which an unquoted .env or shell value expands, corrupting the hash.
It reads no config and touches no database. Run it on the admin's machine. The server never stores the key, so a lost key means generating a new pair.