PRDigest

One merged-PR facts contract. Prose for people and JSON for agents.

CI Gem version Ruby 3.2+ MIT license

Give OpenClaw and other agents stable merged-PR facts, or generate concise
prose through any 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 --> 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]
  • Stable facts for agents — repository order, pull-request order, and JSON shape are deterministic.
  • AI stays explicitfacts never configures 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
Facts for OpenClaw or another agent prdigest facts prdigest-facts JSON on stdout No Telegram, provider, or delivery state
Provider-written text prdigest prose Plain text on stdout Fresh run calls GitHub, then the provider; no Telegram or checkpoint
Scheduled or one-off Telegram prose prdigest prose --deliver Checkpointed plain-text Telegram delivery Fresh run calls GitHub and provider, persists, then sends; resume reuses the checkpoint

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:
  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=...       # 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 Telegram, delivery state, or prose provider
bundle exec prdigest facts --config prdigest.yml

# Provider-written text on stdout: no Telegram or delivery checkpoint
bundle exec prdigest prose --config prdigest.yml

3. Deliver intentionally

# 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.3.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

digest:
  line_stats: true

telegram:
  token_env: TELEGRAM_BOT_TOKEN
  chat_id_allowlist: [-1001234567890]
  chat_id: -1001234567890

state:
  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.
  • chat_id must appear in the non-empty allowlist. Extra IDs are accepted for schema compatibility, but delivery sends only to chat_id.
  • Token values belong in environment variables, never YAML.
  • The prose block is validated only for prdigest prose; facts ignores it.
  • Remote provider URLs require HTTPS. Plaintext HTTP is accepted only for exact loopback hosts.

Command reference

prdigest facts [--config PATH] [--date YYYY-MM-DD] [--repo OWNER/NAME ...]
prdigest prose [--config PATH] [--date YYYY-MM-DD] [--repo OWNER/NAME ...] [--deliver]
prdigest version

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 plain-text chunks are 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 another provider.

Delivery guarantees

PRDigest treats sending as a durable protocol, not a best-effort loop:

  1. Fetch facts and generate the complete prose digest.
  2. Persist the exact final chunk list.
  3. Mark a chunk in flight before sending.
  4. Advance only after Telegram definitely accepts it.
  5. 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.

Once a prose 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.

Date and checkpoint details

Each local day is converted to independent UTC midnight boundaries, including DST gaps and repeats. Both commands use yesterday in the configured timezone unless --date YYYY-MM-DD is supplied.

Checkpoint directories are mode 0700; files and locks are mode 0600. A per-date lock prevents concurrent sends for the same repository scope and chat. While a completed checkpoint exists, repeating prose --deliver for that date is a no-op. Moving that checkpoint aside intentionally allows complete regeneration and redelivery.

The supplied systemd timer invokes prose --deliver once each day. PRDigest does not maintain a catch-up cursor: explicitly run missed dates with --date.

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 checkpoint backup, then restart. Never move a checkpoint forward by hand.

Exit codes and troubleshooting

Exit Meaning First check
0 Completed
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 Checkpoint state failure Path, owner, mode, checkpoint JSON
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. Overlapping delivery for the same date is refused by the checkpoint lock; the systemd oneshot is the normal scheduler.

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