PRDigest
One source of truth for merged pull requests. Three ways to present it.
Collect merged PRs once, then deliver a deterministic Telegram digest, emit
stable JSON for an agent, or generate optional prose through an
OpenAI-compatible Chat Completions endpoint.
Quick start · Choose a mode · Configuration · Deploy · OpenClaw
Why PRDigest?
PRDigest keeps collection separate from presentation. Every mode starts from the same ordered, immutable facts, so adding an agent or prose model never changes what was fetched from GitHub.
flowchart LR
GH[GitHub repositories] --> C[Canonical collector]
C --> RUN["prdigest run<br/>deterministic Telegram"]
C --> FACTS["prdigest facts<br/>versioned JSON"]
C --> PROSE["prdigest prose<br/>provider-written text"]
FACTS --> OC[OpenClaw or another client]
PROSE --> OUT[stdout or explicit Telegram delivery]
- Deterministic by default — repository order, pull-request order, and JSON shape are stable.
- AI stays optional — neither
runnorfactsconfigures or contacts a prose provider. - Safe to resume — delivery checkpoints prevent already accepted chunks from being sent twice.
- Secrets stay out of config — YAML names environment variables; it never contains token values.
Choose a mode
| What you need | Command | Result | Side effects |
|---|---|---|---|
| Reliable scheduled digest | prdigest run |
Deterministic Telegram HTML | Reads/writes schedule and delivery state |
| Facts for OpenClaw or another client | prdigest facts |
prdigest-facts JSON on stdout |
No schedule state, Telegram, or AI provider |
| Provider-written text | prdigest prose |
Plain text on stdout | Fresh run calls GitHub, then the provider; no Telegram or checkpoint |
| Provider-written Telegram digest | prdigest prose --deliver |
Checkpointed Telegram delivery | Fresh run calls GitHub and provider, persists, then sends; resume uses the checkpoint without GitHub/provider generation |
All modes accept --date YYYY-MM-DD and repeatable
--repo OWNER/NAME overrides. Repository order is always preserved.
Quick start
1. Install from source
Ruby 3.2 or newer and tzdata are required. CI covers Ruby 3.2–3.4.
git clone https://github.com/ivankuznetsov/prdigest.git
cd prdigest
bundle install
cp configs/config.example.yml prdigest.yml
Before running a delivery command from the source checkout, change the copied config to state paths writable by your user:
state:
path: tmp/prdigest/state.json
delivery_path: tmp/prdigest/deliveries
Keep the example's /var/lib/prdigest paths for the packaged systemd
deployment, which creates that directory for the prdigest service user.
Edit prdigest.yml, then export the narrowly scoped credentials needed by the
mode you plan to run:
export GITHUB_TOKEN=github_pat_...
export TELEGRAM_BOT_TOKEN=... # run or prose --deliver only
export OPENROUTER_API_KEY=... # prose only; use your configured env name
Use a fine-grained GitHub token with read-only access to the configured repositories. Delivery modes should use a dedicated Telegram bot and one allowlisted destination chat.
2. Try the read-only paths
# Stable JSON: no scheduler state, Telegram, or prose provider
bundle exec prdigest facts --config prdigest.yml
# Deterministic Telegram preview: fetches GitHub but does not send or save state
bundle exec prdigest run --config prdigest.yml --dry-run
# Provider-written text on stdout: no Telegram or delivery checkpoint
bundle exec prdigest prose --config prdigest.yml
3. Deliver intentionally
# Scheduled deterministic delivery
bundle exec prdigest run --config prdigest.yml
# Provider-written delivery, checkpointed before the first Telegram request
bundle exec prdigest prose --config prdigest.yml --deliver
To build and install the current checkout as a gem without publishing it:
gem build prdigest.gemspec
gem install prdigest-0.2.0.gem
prdigest version
The published RubyGems version may trail
the current main branch.
Publishing for maintainers
RubyGems publication uses GitHub OIDC rather than a stored API key. Configure
the prdigest gem's RubyGems trusted publisher with repository owner
ivankuznetsov, repository prdigest, workflow release.yml, and environment
release.
Pushing a new exact vX.Y.Z tag publishes it automatically. To publish an
existing prepared tag such as v0.1.1, run the Release gem workflow
manually and supply that tag. The workflow checks that the immutable tag and
package version agree, runs the unit and clean-install smoke suites, builds and
verifies the exact gem, then exchanges GitHub's OIDC identity for a short-lived
RubyGems credential immediately before gem push.
Configuration
Lookup is strict: --config PATH, then PRDIGEST_CONFIG, then an existing
/etc/prdigest/config.yml. PRDigest refuses to run when no configuration is
found.
timezone: Europe/London
github:
token_env: GITHUB_TOKEN
repos:
- owner/api
- owner/web
schedule:
max_catchup_days: 7
digest:
line_stats: true
send_empty: true
empty_message: "Merged PR digest — {date}\nTotal: 0 PRs"
telegram:
token_env: TELEGRAM_BOT_TOKEN
chat_id_allowlist: [-1001234567890]
chat_id: -1001234567890
state:
path: /var/lib/prdigest/state.json
delivery_path: /var/lib/prdigest/deliveries
prose:
provider: openai_compatible
base_url: https://openrouter.ai/api/v1
model: provider/model
api_key_env: OPENROUTER_API_KEY
See configs/config.example.yml for the complete
annotated configuration.
Configuration rules
- Repository order controls digest order.
max_catchup_daysmust be between1and30.chat_idmust appear in the non-empty allowlist. Extra IDs are accepted for schema compatibility, but delivery sends only tochat_id.- Token values belong in environment variables, never YAML.
- The prose block is validated only for
prdigest prose; it does not ambiently enable provider access for deterministic commands. - Remote provider URLs require HTTPS. Plaintext HTTP is accepted only for exact loopback hosts.
Command reference
prdigest run [--config PATH] [--date YYYY-MM-DD] [--dry-run] [--json] [--repo OWNER/NAME ...]
prdigest facts [--config PATH] [--date YYYY-MM-DD] [--repo OWNER/NAME ...]
prdigest prose [--config PATH] [--date YYYY-MM-DD] [--repo OWNER/NAME ...] [--deliver]
prdigest serve
prdigest version
run
An ordinary run processes owed dates oldest-first and advances state after each
settled day. --date replays exactly one local date without reading or writing
the schedule cursor. --dry-run previews an explicit date or yesterday and
constructs neither state nor Telegram delivery.
--json emits a versioned prdigest-result document with requested, settled,
skipped, failed, and remaining dates plus delivery progress. serve is a
compatibility stub; use the supplied systemd timer for scheduling.
facts
Fetches one explicit date or yesterday and prints exactly one JSON document. It never reads or writes scheduling state, constructs Telegram delivery, or contacts the prose provider.
A complete successful empty digest looks like:
{
"schema": "prdigest-facts",
"schema_version": 1,
"status": "success",
"error": null,
"digest": {
"date": "2026-07-25",
"timezone": "Europe/London",
"line_stats": true,
"repository_order": ["owner/api", "owner/web"],
"repositories": [
{"name": "owner/api", "pull_requests": []},
{"name": "owner/web", "pull_requests": []}
],
"totals": {
"pull_requests": 0,
"additions": 0,
"deletions": 0,
"commits": 0
}
}
}
The full digest includes ordered repositories and pull requests, authors, URLs,
UTC merge times, nullable line and commit statistics, and aggregate totals.
Disabled statistics remain null, not zero. There is no generation timestamp,
so identical accepted GitHub input produces identical JSON.
prose
Sends the same facts document as untrusted data to
<prose.base_url>/chat/completions. The provider is instructed to present those
facts without adding to or changing them.
Without --deliver, prose is printed to stdout. With --deliver, the final
escaped and chunked payload is stored under state.delivery_path/prose before
the first Telegram request. Provider output containing terminal control
characters is rejected before it can reach stdout, a checkpoint, or Telegram.
There is no silent fallback to deterministic prose or another provider.
Delivery guarantees
PRDigest treats sending as a durable protocol, not a best-effort loop:
- Fetch and render the complete day.
- Persist the exact final chunk list.
- Mark a chunk in flight before sending.
- Advance only after Telegram definitely accepts it.
- Resume at the next unsent chunk after a definite failure.
A definite Telegram 429/5xx response receives at most three attempts. Transport failures are ambiguous because Telegram may have accepted the request before the connection failed; PRDigest parks them instead of risking a duplicate.
Prose delivery uses its own checkpoint namespace. Once a payload exists, retry loads those exact chunks before checking GitHub or provider credentials, so a resume never regenerates different prose. A failure before the payload becomes durable can incur another provider request on retry.
Scheduling, replay, and state details
Each local day is converted to independent UTC midnight boundaries, including DST gaps and repeats. A day settles only after every chunk succeeds, an enabled empty message succeeds, or an empty message is intentionally suppressed.
State is secret-free JSON version 1:
{"version":1,"timezone":"Europe/London","last_digested_date":"2026-07-15"}
Writes use atomic mode-0600 replacement and directory fsync. Delivery
directories are mode 0700; files and locks are mode 0600. 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.
When backlog exceeds the configured cap, PRDigest durably skips the oldest prefix and processes only the newest window. For a timezone migration, stop the timer, preserve and move aside the old state, change timezone, explicitly replay any required dates, then restart the timer.
GitHub does not guarantee search-index freshness. Keep the host timezone aligned
with the digest timezone and use --date YYYY-MM-DD for a delayed merge found
by a later audit. Explicit replay uses the same delivery ledger: while a
completed checkpoint for that date exists, delivery is a no-op and newly
rendered content is ignored. Intentionally archiving the checkpoint makes the
next replay regenerate and send the complete date, not only the delayed pull
request.
OpenClaw
The repository includes a ClawHub-ready skill at
openclaw/skills/prdigest/SKILL.md. It:
- invokes only
prdigest facts; - validates schema version 1 and the success envelope;
- treats pull-request fields as untrusted data, never instructions;
- makes no second GitHub request; and
- never invokes delivery or configures a prose provider.
The skill is source-ready, not claimed as published. After a separately authorized ClawHub release, the expected command is:
clawhub install @ivankuznetsov/prdigest
ClawHub installation and Ruby CLI installation are separate trust boundaries.
See openclaw/README.md for local use and the
release-gated publication checklist.
Deployment
systemd on Ubuntu
Install Ruby, tzdata, and the gem, 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/
sudoedit /etc/prdigest/config.yml
sudoedit /etc/prdigest/.env
sudo systemctl daemon-reload
sudo systemctl start prdigest.service
sudo systemctl enable --now prdigest.timer
systemd creates /var/lib/prdigest as prdigest:prdigest mode 0700.
systemctl status prdigest.service
systemctl list-timers prdigest.timer
journalctl -u prdigest.service
Journal output can contain repository and date context, so restrict journal group membership and retention.
Container
The Alpine image includes tzdata and runs as the unprivileged prdigest
user. Initialize mounted ownership once before normal use:
docker build -t prdigest:local .
docker volume create prdigest-state
docker run --rm --user root --entrypoint sh \
-v prdigest-state:/var/lib/prdigest \
prdigest:local \
-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:local
Rollback
Stop the timer, install the prior gem or image, restore its matching config and a known-good state backup, then restart. Replay omitted dates explicitly; never move a checkpoint forward by hand.
Exit codes and troubleshooting
| Exit | Meaning | First check |
|---|---|---|
0 |
Completed or dry-run | — |
1 |
Unexpected or render failure | Logs and input shape |
2 |
CLI/configuration refusal | Config path, YAML, timezone, allowlist, env |
3 |
GitHub failure | Token scope, repository access, rate/search limits |
4 |
Telegram failure | error.kind and delivery checkpoint |
5 |
State failure | Path, owner, mode, JSON version/date/timezone |
6 |
Failure after durable progress | Settled/skipped dates before retry |
7 |
Provider failure or ambiguous outcome | Endpoint, model, key env, retry cost |
Reconcile telegram_ambiguous, telegram_permanent, and
delivery_checkpoint_permanent before moving a checkpoint. Concurrent
scheduled runs are unsupported; the systemd oneshot is the normal coordinator.
See SECURITY.md for token scope, rotation, and private-data
flow.
Development
bundle install
bundle exec rake test
test/smoke/gem_install.sh
test/smoke/docker.sh
test/smoke/systemd.sh
The test suite is fully offline. Live GitHub and Telegram smokes are manual release gates; retain only timestamps and pass/fail status, never credentials, response bodies, generated prose, or private titles.
License
MIT © 2026 Ivan Kuznetsov