PRDigest
PRDigest is a deterministic Ruby oneshot that sends a daily digest of merged GitHub pull requests to one allowlisted Telegram chat. It is designed for a single operator on a small VPS: repositories are explicit, secrets stay in the environment, and a hardened systemd timer owns scheduling.
Requirements and installation
- Ruby 3.2–3.4 and
tzdata - a fine-grained GitHub token with read-only access to the listed repositories
- a dedicated Telegram bot and one destination chat ID
For development:
bundle install
bundle exec prdigest version
GITHUB_TOKEN=... bundle exec prdigest run --config configs/config.example.yml --dry-run
To build and install the prepared gem without publishing it:
gem build prdigest.gemspec
gem install prdigest-0.1.1.gem
prdigest version
Configuration and secrets
Configuration lookup is strict: an explicit --config PATH wins, then
PRDIGEST_CONFIG, then an existing /etc/prdigest/config.yml. PRDigest refuses
to run when none exists. See configs/config.example.yml for every setting.
timezone: Europe/London
schedule:
max_catchup_days: 7
github:
token_env: GITHUB_TOKEN
repos:
- owner/repo-one
- owner/repo-two
telegram:
token_env: TELEGRAM_BOT_TOKEN
chat_id_allowlist: [-1001234567890]
chat_id: -1001234567890
digest:
line_stats: true
send_empty: true
empty_message: "Merged PR digest — {date}\nTotal: 0 PRs"
state:
path: /var/lib/prdigest/state.json
delivery_path: /var/lib/prdigest/deliveries
Repository order controls digest order. max_catchup_days must be 1..30.
chat_id must appear in the non-empty allowlist; extra allowlisted IDs are
accepted for schema compatibility but v0.1.x sends only to chat_id. Token
values belong in environment variables, never YAML.
Commands and results
prdigest run [--config PATH] [--date YYYY-MM-DD] [--dry-run] [--json] [--repo OWNER/NAME ...]
prdigest serve
prdigest version
An ordinary run reads state, processes owed dates oldest-first, and writes
state after each settled day. --date replays exactly that local date and never
reads or writes state. --dry-run previews the explicit date or yesterday,
requires GitHub access, and constructs neither state nor Telegram delivery.
serve remains a compatibility stub; use the supplied timer.
Repeatable --repo values replace the configured repository list for that run.
They use the same strict owner/name validation and deterministic order as the
configuration, which lets callers such as Hive supply their registered projects
without owning a second digest implementation.
--json emits a versioned prdigest-result document with status, mode, requested_days,
settled_days, skipped_days, failed_date, remaining_days, nullable
error, dry-run chunks, and nullable structured delivery progress.
delivery reports accepted_chunks, total_chunks, status, and, on failure,
failed_chunk. Modes are scheduled and
explicit_date_replay; statuses are success, dry_run, failure, and
partial_failure.
| Exit | Meaning |
|---|---|
| 0 | completed or dry-run |
| 1 | unexpected or render failure |
| 2 | CLI/configuration refusal |
| 3 | GitHub failure |
| 4 | Telegram failure |
| 5 | state failure |
| 6 | failure after earlier durable progress |
Delivery and state semantics
Each local day is converted to independent UTC midnight boundaries, including DST gaps and repeats. GitHub results are completely fetched and rendered before the first Telegram message. A day is settled only after every chunk succeeds, an enabled empty message succeeds, or an empty message is intentionally suppressed.
Before sending, PRDigest stores the complete rendered chunk list in
state.delivery_path. It marks one chunk in flight before each request and
advances next_chunk only after Telegram definitely accepts it. A definite
429/5xx rejection receives at most three attempts; a later invocation resumes
the original rendered payload at the next unsent chunk. If the date cursor write
fails after complete delivery, the retry observes the completed delivery
checkpoint and sends nothing before repairing the cursor.
Telegram 400/invalid-response failures are permanent. Transport failures are
ambiguous because Telegram might have accepted the request before the
connection failed. Both cases remain parked and never replay automatically.
Inspect the checkpoint and Telegram chat, reconcile the uncertain chunk, then
move the checkpoint aside only when an intentional operator replay is safe.
Changing the chat or repository scope for an existing date also fails closed.
Explicit --date uses this same safety ledger, so a completed date is a no-op
unless its checkpoint is deliberately archived first.
State is secret-free JSON version 1:
{"version":1,"timezone":"Europe/London","last_digested_date":"2026-07-15"}
It may include a last_skip audit with start_date, end_date, and
notice_pending. Writes use an atomic mode-0600 replacement and directory
fsync. Delivery directories are mode 0700; delivery files and locks are mode
0600, and a per-date lock prevents concurrent sends for the same checkpoint.
Missing state means first run and requests yesterday only. Malformed,
future, unsupported, unreadable, or timezone-mismatched state fails closed with
exit 5.
When backlog exceeds the cap, PRDigest durably skips the oldest prefix and processes only the newest window. That loss is intentional and reported. For a timezone migration, stop the timer, preserve the old state for audit, move it aside, change the configured timezone, use explicit replay for any required dates, then restart the timer. Never silently edit a corrupt checkpoint; inspect and repair ownership/JSON or restore a known-good copy first.
GitHub does not publish a search-index freshness guarantee. Keep the host
timezone equal to the configured digest timezone; the supplied 09:05 timer then
leaves about nine hours after local midnight. If a later audit finds a delayed
merge, use --date YYYY-MM-DD to replay it.
systemd deployment
On Ubuntu, install Ruby, tzdata, the gem, and then:
sudo useradd --system --home /nonexistent --shell /usr/sbin/nologin prdigest
sudo install -d -o root -g prdigest -m 0750 /etc/prdigest
sudo install -o root -g prdigest -m 0640 configs/config.example.yml /etc/prdigest/config.yml
sudo install -o root -g root -m 0600 .env.example /etc/prdigest/.env
sudo install -o root -g root -m 0644 scripts/systemd/prdigest.service /etc/systemd/system/
sudo install -o root -g root -m 0644 scripts/systemd/prdigest.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl start prdigest.service
sudo systemctl enable --now prdigest.timer
Edit the installed config and environment file before starting. systemd creates
/var/lib/prdigest as prdigest:prdigest mode 0700; state files are 0600.
Inspect with systemctl status prdigest.service, systemctl list-timers prdigest.timer, and journalctl -u prdigest.service. Test a missed-day catch-up
by stopping the timer for a day, then starting the service. Journal output can
contain repository/date context, so restrict journal group membership and
retention.
Rollback by stopping the timer, installing the prior gem/image, restoring the matching config and a known-good state backup, and restarting. Replay omitted dates explicitly; do not move a checkpoint forward by hand.
Container
The Alpine image includes tzdata and runs as the unprivileged prdigest user.
Initialize mounted ownership with the image before normal use:
docker build -t prdigest:0.1.1 .
docker volume create prdigest-state
docker run --rm --user root --entrypoint sh -v prdigest-state:/var/lib/prdigest prdigest:0.1.1 \
-c 'chown -R prdigest:prdigest /var/lib/prdigest && chmod 0700 /var/lib/prdigest'
docker run --rm --env-file /etc/prdigest/.env \
-v /etc/prdigest/config.yml:/etc/prdigest/config.yml:ro \
-v prdigest-state:/var/lib/prdigest prdigest:0.1.1
Verification and troubleshooting
bundle exec rake test is fully offline. Release preparation also supplies
test/smoke/gem_install.sh, test/smoke/docker.sh, and
test/smoke/systemd.sh. Live GitHub boundary and Telegram allowlist smokes are
manual gates: retain timestamps and pass/fail only, never credentials, response
bodies, message text, or private titles.
- Exit 2: check config discovery, YAML, timezone, cap, chat allowlist, and env.
- Exit 3: check repository access, rate/search limits, and replay later.
- Exit 4: inspect
error.kindanddelivery. Retry ordinarytelegramfailures; reconciletelegram_ambiguous,telegram_permanent, anddelivery_checkpoint_permanentbefore moving any checkpoint. - Exit 5: check
/var/lib/prdigest, mode/owner, JSON version, date, and timezone. - Exit 6: earlier dates or a skipped prefix are durable; inspect the result before retry.
Concurrent scheduled runs remain unsupported because date-cursor scheduling is single-owner. Delivery itself takes a nonblocking per-date lock, so a competing sender fails instead of duplicating the same payload. The systemd oneshot is the normal run coordination mechanism.
See SECURITY.md for token scope, rotation, and private-data flow.
Non-goals
v0.1.x has no built-in Hive configuration discovery, LLM content, web UI, interactive bot commands, multi-chat routing, organization discovery, non-GitHub forge support, or long-running scheduler. The build and test process never tags or publishes automatically.
License
MIT