prdigest 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +33 -83
- data/SECURITY.md +4 -5
- data/configs/config.example.yml +3 -12
- data/lib/prdigest/cli.rb +24 -74
- data/lib/prdigest/config.rb +4 -29
- data/lib/prdigest/prose_renderer.rb +3 -6
- data/lib/prdigest/telegram.rb +0 -1
- data/lib/prdigest/version.rb +1 -1
- data/lib/prdigest.rb +0 -5
- data/scripts/systemd/prdigest.service +1 -1
- metadata +4 -9
- data/lib/prdigest/renderer.rb +0 -134
- data/lib/prdigest/result.rb +0 -83
- data/lib/prdigest/runner.rb +0 -168
- data/lib/prdigest/schedule.rb +0 -30
- data/lib/prdigest/state.rb +0 -147
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9ff60040fe7091056de1781ae7154e758145cf7216ec1b664adc661cd479c96f
|
|
4
|
+
data.tar.gz: aa87baccb18ac37b7398c83e5d312187e3d697fa8fa9e161c6f3b930cef73ae4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2e7499399445ffa51a7acb8bd0f7066bf3f930108d56f070cafdfdc49e4a8b6727b6288b3a5e95cae3ed36f6e01c19fa90726522c6aeb731a45edf1c6a88718a
|
|
7
|
+
data.tar.gz: 7bc1c60e8518b9abebeb1beb6fdb08b12aafb39beb4056db7f96fc225b2344e9643f5d27108138a4eecb06d100db16f55c18dd075ffdc35152087b7f5ad58dc3
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 - 2026-07-27
|
|
4
|
+
|
|
5
|
+
- Remove `prdigest run`, deterministic Telegram HTML, schedule/cursor state,
|
|
6
|
+
catch-up processing, the `prdigest-result` envelope, and the `serve` stub.
|
|
7
|
+
- Keep `prdigest facts` for agents and `prdigest prose` for stdout or
|
|
8
|
+
checkpointed plain-text Telegram delivery.
|
|
9
|
+
- Make `prdigest prose --deliver` the systemd and container default.
|
|
10
|
+
|
|
3
11
|
## 0.2.0 - 2026-07-26
|
|
4
12
|
|
|
5
13
|
- Add a deterministic `prdigest facts` JSON contract over the canonical
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
<h1>PRDigest</h1>
|
|
4
4
|
|
|
5
|
-
<p><strong>One
|
|
5
|
+
<p><strong>One merged-PR facts contract. Prose for people and JSON for agents.</strong></p>
|
|
6
6
|
|
|
7
7
|
<p>
|
|
8
8
|
<a href="https://github.com/ivankuznetsov/prdigest/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/ivankuznetsov/prdigest/actions/workflows/ci.yml/badge.svg"></a>
|
|
@@ -12,9 +12,8 @@
|
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
14
|
<p>
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
OpenAI-compatible Chat Completions endpoint.
|
|
15
|
+
Give OpenClaw and other agents stable merged-PR facts, or generate concise<br>
|
|
16
|
+
prose through any OpenAI-compatible Chat Completions endpoint.
|
|
18
17
|
</p>
|
|
19
18
|
|
|
20
19
|
<p>
|
|
@@ -38,17 +37,15 @@ changes what was fetched from GitHub.
|
|
|
38
37
|
```mermaid
|
|
39
38
|
flowchart LR
|
|
40
39
|
GH[GitHub repositories] --> C[Canonical collector]
|
|
41
|
-
C --> RUN["prdigest run<br/>deterministic Telegram"]
|
|
42
40
|
C --> FACTS["prdigest facts<br/>versioned JSON"]
|
|
43
41
|
C --> PROSE["prdigest prose<br/>provider-written text"]
|
|
44
42
|
FACTS --> OC[OpenClaw or another client]
|
|
45
43
|
PROSE --> OUT[stdout or explicit Telegram delivery]
|
|
46
44
|
```
|
|
47
45
|
|
|
48
|
-
- **
|
|
49
|
-
shape are
|
|
50
|
-
- **AI stays
|
|
51
|
-
prose provider.
|
|
46
|
+
- **Stable facts for agents** — repository order, pull-request order, and JSON
|
|
47
|
+
shape are deterministic.
|
|
48
|
+
- **AI stays explicit** — `facts` never configures or contacts a prose provider.
|
|
52
49
|
- **Safe to resume** — delivery checkpoints prevent already accepted chunks
|
|
53
50
|
from being sent twice.
|
|
54
51
|
- **Secrets stay out of config** — YAML names environment variables; it never
|
|
@@ -58,10 +55,9 @@ flowchart LR
|
|
|
58
55
|
|
|
59
56
|
| What you need | Command | Result | Side effects |
|
|
60
57
|
|---|---|---|---|
|
|
61
|
-
|
|
|
62
|
-
| Facts for OpenClaw or another client | `prdigest facts` | `prdigest-facts` JSON on stdout | No schedule state, Telegram, or AI provider |
|
|
58
|
+
| Facts for OpenClaw or another agent | `prdigest facts` | `prdigest-facts` JSON on stdout | No Telegram, provider, or delivery state |
|
|
63
59
|
| Provider-written text | `prdigest prose` | Plain text on stdout | Fresh run calls GitHub, then the provider; no Telegram or checkpoint |
|
|
64
|
-
|
|
|
60
|
+
| 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 |
|
|
65
61
|
|
|
66
62
|
All modes accept `--date YYYY-MM-DD` and repeatable
|
|
67
63
|
`--repo OWNER/NAME` overrides. Repository order is always preserved.
|
|
@@ -84,7 +80,6 @@ config to state paths writable by your user:
|
|
|
84
80
|
|
|
85
81
|
```yaml
|
|
86
82
|
state:
|
|
87
|
-
path: tmp/prdigest/state.json
|
|
88
83
|
delivery_path: tmp/prdigest/deliveries
|
|
89
84
|
```
|
|
90
85
|
|
|
@@ -96,7 +91,7 @@ mode you plan to run:
|
|
|
96
91
|
|
|
97
92
|
```sh
|
|
98
93
|
export GITHUB_TOKEN=github_pat_...
|
|
99
|
-
export TELEGRAM_BOT_TOKEN=... #
|
|
94
|
+
export TELEGRAM_BOT_TOKEN=... # prose --deliver only
|
|
100
95
|
export OPENROUTER_API_KEY=... # prose only; use your configured env name
|
|
101
96
|
```
|
|
102
97
|
|
|
@@ -107,12 +102,9 @@ allowlisted destination chat.
|
|
|
107
102
|
### 2. Try the read-only paths
|
|
108
103
|
|
|
109
104
|
```sh
|
|
110
|
-
# Stable JSON: no
|
|
105
|
+
# Stable JSON: no Telegram, delivery state, or prose provider
|
|
111
106
|
bundle exec prdigest facts --config prdigest.yml
|
|
112
107
|
|
|
113
|
-
# Deterministic Telegram preview: fetches GitHub but does not send or save state
|
|
114
|
-
bundle exec prdigest run --config prdigest.yml --dry-run
|
|
115
|
-
|
|
116
108
|
# Provider-written text on stdout: no Telegram or delivery checkpoint
|
|
117
109
|
bundle exec prdigest prose --config prdigest.yml
|
|
118
110
|
```
|
|
@@ -120,9 +112,6 @@ bundle exec prdigest prose --config prdigest.yml
|
|
|
120
112
|
### 3. Deliver intentionally
|
|
121
113
|
|
|
122
114
|
```sh
|
|
123
|
-
# Scheduled deterministic delivery
|
|
124
|
-
bundle exec prdigest run --config prdigest.yml
|
|
125
|
-
|
|
126
115
|
# Provider-written delivery, checkpointed before the first Telegram request
|
|
127
116
|
bundle exec prdigest prose --config prdigest.yml --deliver
|
|
128
117
|
```
|
|
@@ -131,7 +120,7 @@ To build and install the current checkout as a gem without publishing it:
|
|
|
131
120
|
|
|
132
121
|
```sh
|
|
133
122
|
gem build prdigest.gemspec
|
|
134
|
-
gem install prdigest-0.
|
|
123
|
+
gem install prdigest-0.3.0.gem
|
|
135
124
|
prdigest version
|
|
136
125
|
```
|
|
137
126
|
|
|
@@ -167,13 +156,8 @@ github:
|
|
|
167
156
|
- owner/api
|
|
168
157
|
- owner/web
|
|
169
158
|
|
|
170
|
-
schedule:
|
|
171
|
-
max_catchup_days: 7
|
|
172
|
-
|
|
173
159
|
digest:
|
|
174
160
|
line_stats: true
|
|
175
|
-
send_empty: true
|
|
176
|
-
empty_message: "Merged PR digest — {date}\nTotal: 0 PRs"
|
|
177
161
|
|
|
178
162
|
telegram:
|
|
179
163
|
token_env: TELEGRAM_BOT_TOKEN
|
|
@@ -181,7 +165,6 @@ telegram:
|
|
|
181
165
|
chat_id: -1001234567890
|
|
182
166
|
|
|
183
167
|
state:
|
|
184
|
-
path: /var/lib/prdigest/state.json
|
|
185
168
|
delivery_path: /var/lib/prdigest/deliveries
|
|
186
169
|
|
|
187
170
|
prose:
|
|
@@ -198,12 +181,10 @@ annotated configuration.
|
|
|
198
181
|
<summary><strong>Configuration rules</strong></summary>
|
|
199
182
|
|
|
200
183
|
- Repository order controls digest order.
|
|
201
|
-
- `max_catchup_days` must be between `1` and `30`.
|
|
202
184
|
- `chat_id` must appear in the non-empty allowlist. Extra IDs are accepted for
|
|
203
185
|
schema compatibility, but delivery sends only to `chat_id`.
|
|
204
186
|
- Token values belong in environment variables, never YAML.
|
|
205
|
-
- The prose block is validated only for `prdigest prose`;
|
|
206
|
-
enable provider access for deterministic commands.
|
|
187
|
+
- The prose block is validated only for `prdigest prose`; `facts` ignores it.
|
|
207
188
|
- Remote provider URLs require HTTPS. Plaintext HTTP is accepted only for exact
|
|
208
189
|
loopback hosts.
|
|
209
190
|
|
|
@@ -212,24 +193,11 @@ annotated configuration.
|
|
|
212
193
|
## Command reference
|
|
213
194
|
|
|
214
195
|
```text
|
|
215
|
-
prdigest run [--config PATH] [--date YYYY-MM-DD] [--dry-run] [--json] [--repo OWNER/NAME ...]
|
|
216
196
|
prdigest facts [--config PATH] [--date YYYY-MM-DD] [--repo OWNER/NAME ...]
|
|
217
197
|
prdigest prose [--config PATH] [--date YYYY-MM-DD] [--repo OWNER/NAME ...] [--deliver]
|
|
218
|
-
prdigest serve
|
|
219
198
|
prdigest version
|
|
220
199
|
```
|
|
221
200
|
|
|
222
|
-
### `run`
|
|
223
|
-
|
|
224
|
-
An ordinary run processes owed dates oldest-first and advances state after each
|
|
225
|
-
settled day. `--date` replays exactly one local date without reading or writing
|
|
226
|
-
the schedule cursor. `--dry-run` previews an explicit date or yesterday and
|
|
227
|
-
constructs neither state nor Telegram delivery.
|
|
228
|
-
|
|
229
|
-
`--json` emits a versioned `prdigest-result` document with requested, settled,
|
|
230
|
-
skipped, failed, and remaining dates plus delivery progress. `serve` is a
|
|
231
|
-
compatibility stub; use the supplied systemd timer for scheduling.
|
|
232
|
-
|
|
233
201
|
### `facts`
|
|
234
202
|
|
|
235
203
|
Fetches one explicit date or yesterday and prints exactly one JSON document. It
|
|
@@ -275,17 +243,17 @@ Sends the same facts document as untrusted data to
|
|
|
275
243
|
facts without adding to or changing them.
|
|
276
244
|
|
|
277
245
|
Without `--deliver`, prose is printed to stdout. With `--deliver`, the final
|
|
278
|
-
|
|
279
|
-
|
|
246
|
+
plain-text chunks are stored under `state.delivery_path/prose` before the first
|
|
247
|
+
Telegram request. Provider output containing terminal control
|
|
280
248
|
characters is rejected before it can reach stdout, a checkpoint, or Telegram.
|
|
281
249
|
|
|
282
|
-
There is no silent fallback to
|
|
250
|
+
There is no silent fallback to another provider.
|
|
283
251
|
|
|
284
252
|
## Delivery guarantees
|
|
285
253
|
|
|
286
254
|
PRDigest treats sending as a durable protocol, not a best-effort loop:
|
|
287
255
|
|
|
288
|
-
1. Fetch and
|
|
256
|
+
1. Fetch facts and generate the complete prose digest.
|
|
289
257
|
2. Persist the exact final chunk list.
|
|
290
258
|
3. Mark a chunk in flight before sending.
|
|
291
259
|
4. Advance only after Telegram definitely accepts it.
|
|
@@ -295,43 +263,26 @@ A definite Telegram 429/5xx response receives at most three attempts. Transport
|
|
|
295
263
|
failures are ambiguous because Telegram may have accepted the request before
|
|
296
264
|
the connection failed; PRDigest parks them instead of risking a duplicate.
|
|
297
265
|
|
|
298
|
-
|
|
266
|
+
Once a prose payload exists, retry
|
|
299
267
|
loads those exact chunks before checking GitHub or provider credentials, so a
|
|
300
268
|
resume never regenerates different prose. A failure before the payload becomes
|
|
301
269
|
durable can incur another provider request on retry.
|
|
302
270
|
|
|
303
271
|
<details>
|
|
304
|
-
<summary><strong>
|
|
272
|
+
<summary><strong>Date and checkpoint details</strong></summary>
|
|
305
273
|
|
|
306
274
|
Each local day is converted to independent UTC midnight boundaries, including
|
|
307
|
-
DST gaps and repeats.
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
State is secret-free JSON version 1:
|
|
311
|
-
|
|
312
|
-
```json
|
|
313
|
-
{"version":1,"timezone":"Europe/London","last_digested_date":"2026-07-15"}
|
|
314
|
-
```
|
|
315
|
-
|
|
316
|
-
Writes use atomic mode-`0600` replacement and directory fsync. Delivery
|
|
317
|
-
directories are mode `0700`; files and locks are mode `0600`. A per-date lock
|
|
318
|
-
prevents concurrent sends for the same checkpoint.
|
|
319
|
-
|
|
320
|
-
Missing state means first run and requests yesterday only. Malformed, future,
|
|
321
|
-
unsupported, unreadable, or timezone-mismatched state fails closed.
|
|
275
|
+
DST gaps and repeats. Both commands use yesterday in the configured timezone
|
|
276
|
+
unless `--date YYYY-MM-DD` is supplied.
|
|
322
277
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
278
|
+
Checkpoint directories are mode `0700`; files and locks are mode `0600`.
|
|
279
|
+
A per-date lock prevents concurrent sends for the same repository scope and
|
|
280
|
+
chat. While a completed checkpoint exists, repeating `prose --deliver` for that
|
|
281
|
+
date is a no-op. Moving that checkpoint aside intentionally allows complete
|
|
282
|
+
regeneration and redelivery.
|
|
327
283
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
by a later audit. Explicit replay uses the same delivery ledger: while a
|
|
331
|
-
completed checkpoint for that date exists, delivery is a no-op and newly
|
|
332
|
-
rendered content is ignored. Intentionally archiving the checkpoint makes the
|
|
333
|
-
next replay regenerate and send the complete date, not only the delayed pull
|
|
334
|
-
request.
|
|
284
|
+
The supplied systemd timer invokes `prose --deliver` once each day. PRDigest
|
|
285
|
+
does not maintain a catch-up cursor: explicitly run missed dates with `--date`.
|
|
335
286
|
|
|
336
287
|
</details>
|
|
337
288
|
|
|
@@ -416,25 +367,24 @@ docker run --rm --env-file /etc/prdigest/.env \
|
|
|
416
367
|
### Rollback
|
|
417
368
|
|
|
418
369
|
Stop the timer, install the prior gem or image, restore its matching config and
|
|
419
|
-
|
|
420
|
-
move a checkpoint forward by hand.
|
|
370
|
+
checkpoint backup, then restart. Never move a checkpoint forward by hand.
|
|
421
371
|
|
|
422
372
|
## Exit codes and troubleshooting
|
|
423
373
|
|
|
424
374
|
| Exit | Meaning | First check |
|
|
425
375
|
|---:|---|---|
|
|
426
|
-
| `0` | Completed
|
|
376
|
+
| `0` | Completed | — |
|
|
427
377
|
| `1` | Unexpected or render failure | Logs and input shape |
|
|
428
378
|
| `2` | CLI/configuration refusal | Config path, YAML, timezone, allowlist, env |
|
|
429
379
|
| `3` | GitHub failure | Token scope, repository access, rate/search limits |
|
|
430
380
|
| `4` | Telegram failure | `error.kind` and delivery checkpoint |
|
|
431
|
-
| `5` |
|
|
432
|
-
| `6` | Failure after durable progress | Settled/skipped dates before retry |
|
|
381
|
+
| `5` | Checkpoint state failure | Path, owner, mode, checkpoint JSON |
|
|
433
382
|
| `7` | Provider failure or ambiguous outcome | Endpoint, model, key env, retry cost |
|
|
434
383
|
|
|
435
384
|
Reconcile `telegram_ambiguous`, `telegram_permanent`, and
|
|
436
|
-
`delivery_checkpoint_permanent` before moving a checkpoint.
|
|
437
|
-
|
|
385
|
+
`delivery_checkpoint_permanent` before moving a checkpoint. Overlapping delivery
|
|
386
|
+
for the same date is refused by the checkpoint lock; the systemd oneshot is the
|
|
387
|
+
normal scheduler.
|
|
438
388
|
|
|
439
389
|
See [`SECURITY.md`](SECURITY.md) for token scope, rotation, and private-data
|
|
440
390
|
flow.
|
data/SECURITY.md
CHANGED
|
@@ -35,8 +35,8 @@ and model boundary so OpenClaw can write prose. Standalone `prdigest prose`
|
|
|
35
35
|
sends the same complete document plus the configured model name to the
|
|
36
36
|
OpenAI-compatible endpoint. Private repositories therefore require an OpenClaw
|
|
37
37
|
deployment or provider whose data handling, retention, and access controls are
|
|
38
|
-
acceptable to the operator.
|
|
39
|
-
|
|
38
|
+
acceptable to the operator. Provider access is never ambient: `prdigest facts`
|
|
39
|
+
does not contact the standalone prose provider.
|
|
40
40
|
|
|
41
41
|
Pull-request fields are untrusted input. The built-in provider prompt and the
|
|
42
42
|
OpenClaw skill explicitly classify the facts JSON as data, never instructions,
|
|
@@ -45,9 +45,8 @@ processing private repository content. The OpenClaw skill must not make a second
|
|
|
45
45
|
GitHub query, deliver messages, install software silently, or print credentials.
|
|
46
46
|
|
|
47
47
|
Prose Telegram checkpoints contain generated text derived from repository facts.
|
|
48
|
-
They use
|
|
49
|
-
|
|
50
|
-
delete them as private repository metadata.
|
|
48
|
+
They use secret-free mode-`0600` files inside mode-`0700` directories. Protect,
|
|
49
|
+
retain, and delete them as private repository metadata.
|
|
51
50
|
|
|
52
51
|
## Reporting
|
|
53
52
|
|
data/configs/config.example.yml
CHANGED
|
@@ -1,12 +1,6 @@
|
|
|
1
|
-
# prdigest —
|
|
1
|
+
# prdigest — merged-PR facts for agents and provider-written prose
|
|
2
2
|
|
|
3
3
|
timezone: Europe/London
|
|
4
|
-
schedule:
|
|
5
|
-
# Prefer systemd timer + `prdigest run` for v1.
|
|
6
|
-
# serve mode may honor this later.
|
|
7
|
-
cron: "5 9 * * *"
|
|
8
|
-
# Newest retained catch-up window; valid range is 1..30.
|
|
9
|
-
max_catchup_days: 7
|
|
10
4
|
|
|
11
5
|
github:
|
|
12
6
|
token_env: GITHUB_TOKEN
|
|
@@ -23,11 +17,9 @@ telegram:
|
|
|
23
17
|
|
|
24
18
|
digest:
|
|
25
19
|
line_stats: true
|
|
26
|
-
send_empty: true
|
|
27
|
-
empty_message: "Merged PR digest — {date}\nTotal: 0 PRs"
|
|
28
20
|
|
|
29
21
|
prose:
|
|
30
|
-
# Used only by `prdigest prose`; `
|
|
22
|
+
# Used only by `prdigest prose`; `facts` never contacts this endpoint.
|
|
31
23
|
provider: openai_compatible
|
|
32
24
|
base_url: https://openrouter.ai/api/v1
|
|
33
25
|
# Replace with a model identifier accepted by the configured endpoint.
|
|
@@ -36,6 +28,5 @@ prose:
|
|
|
36
28
|
api_key_env: OPENROUTER_API_KEY
|
|
37
29
|
|
|
38
30
|
state:
|
|
39
|
-
|
|
40
|
-
# Stable rendered chunks and next-unsent position, separate from the date cursor.
|
|
31
|
+
# Stable prose chunks and the next-unsent position.
|
|
41
32
|
delivery_path: /var/lib/prdigest/deliveries
|
data/lib/prdigest/cli.rb
CHANGED
|
@@ -8,9 +8,15 @@ module Prdigest
|
|
|
8
8
|
class CLI < Thor
|
|
9
9
|
class ParseError < StandardError; end
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
EXIT_CODES = {
|
|
12
|
+
"config" => 2, "cli" => 2, "refusal" => 2,
|
|
13
|
+
"github" => 3,
|
|
14
|
+
"telegram" => 4, "telegram_refused" => 4, "telegram_permanent" => 4,
|
|
15
|
+
"telegram_ambiguous" => 4, "delivery_checkpoint" => 4,
|
|
16
|
+
"delivery_checkpoint_permanent" => 4,
|
|
17
|
+
"state" => 5,
|
|
18
|
+
"provider" => 7, "provider_ambiguous" => 7
|
|
19
|
+
}.freeze
|
|
14
20
|
|
|
15
21
|
desc "facts", "Print deterministic merged-PR facts as JSON"
|
|
16
22
|
def facts; end
|
|
@@ -21,14 +27,10 @@ module Prdigest
|
|
|
21
27
|
desc "version", "Print version"
|
|
22
28
|
def version; end
|
|
23
29
|
|
|
24
|
-
desc "serve", "Compatibility stub; use the systemd timer"
|
|
25
|
-
def serve; end
|
|
26
|
-
|
|
27
30
|
class << self
|
|
28
31
|
def invoke(argv = ARGV, out: $stdout, err: $stderr, env: ENV,
|
|
29
|
-
system_path: "/etc/prdigest/config.yml",
|
|
30
|
-
|
|
31
|
-
json_intent = Array(argv).any? { |arg| arg == "--json" || arg.start_with?("--json=") }
|
|
32
|
+
system_path: "/etc/prdigest/config.yml", facts_runner_factory: nil,
|
|
33
|
+
prose_runner_factory: nil)
|
|
32
34
|
intent = command_intent(argv)
|
|
33
35
|
facts_intent = intent == "facts"
|
|
34
36
|
prose_intent = intent == "prose"
|
|
@@ -38,17 +40,11 @@ module Prdigest
|
|
|
38
40
|
raise ParseError, "--help is not valid for facts" if facts_intent && parsed[:command] == "help"
|
|
39
41
|
return print_help(out) if %w[help --help -h].include?(parsed[:command])
|
|
40
42
|
|
|
41
|
-
unless %w[facts prose
|
|
42
|
-
raise ParseError, "unknown command"
|
|
43
|
-
end
|
|
43
|
+
raise ParseError, "unknown command" unless %w[facts prose].include?(parsed[:command])
|
|
44
44
|
|
|
45
45
|
path = Config.resolve_path(explicit: parsed[:config], env: env, system_path: system_path)
|
|
46
46
|
capability = config_capability(parsed)
|
|
47
47
|
config = Config.load(path, capability: capability)
|
|
48
|
-
if parsed[:command] == "serve"
|
|
49
|
-
err.puts "prdigest serve: not implemented — use systemd timer + `prdigest run`"
|
|
50
|
-
return 0
|
|
51
|
-
end
|
|
52
48
|
|
|
53
49
|
validate_date!(parsed[:date]) if parsed[:date]
|
|
54
50
|
repositories = if parsed[:repos].empty?
|
|
@@ -91,36 +87,16 @@ module Prdigest
|
|
|
91
87
|
present_prose(outcome, out: out)
|
|
92
88
|
return 0
|
|
93
89
|
end
|
|
94
|
-
|
|
95
|
-
raise ConfigError, "GitHub token is missing" if config.github_token(env).empty?
|
|
96
|
-
if !parsed[:dry_run] && config.telegram_token(env).empty?
|
|
97
|
-
raise ConfigError, "Telegram bot token is missing"
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
factory = runner_factory || ->(**options) { Runner.new(**options) }
|
|
101
|
-
result = factory.call(
|
|
102
|
-
config: config,
|
|
103
|
-
date: parsed[:date],
|
|
104
|
-
dry_run: parsed[:dry_run],
|
|
105
|
-
repositories: repositories,
|
|
106
|
-
env: env
|
|
107
|
-
).call
|
|
108
|
-
present(result, json: parsed[:json], out: out, err: err)
|
|
109
|
-
result.exit_code
|
|
110
90
|
rescue ParseError => e
|
|
111
91
|
return present_facts_failure(out, "cli", e.message) if facts_intent
|
|
112
92
|
return present_prose_failure(err, "cli", e.message) if prose_intent
|
|
113
93
|
|
|
114
|
-
|
|
115
|
-
present(result, json: json_intent, out: out, err: err)
|
|
116
|
-
result.exit_code
|
|
94
|
+
present_cli_failure(err, "cli", e.message)
|
|
117
95
|
rescue ConfigError => e
|
|
118
96
|
return present_facts_failure(out, "config", e.message) if facts_intent
|
|
119
97
|
return present_prose_failure(err, "config", safe_prose_message(e, config: config, env: env)) if prose_intent
|
|
120
98
|
|
|
121
|
-
|
|
122
|
-
present(result, json: parsed ? parsed[:json] : json_intent, out: out, err: err)
|
|
123
|
-
result.exit_code
|
|
99
|
+
present_cli_failure(err, "config", e.message)
|
|
124
100
|
rescue StandardError => e
|
|
125
101
|
if facts_intent
|
|
126
102
|
kind = e.is_a?(FetchError) ? e.kind : "internal"
|
|
@@ -133,13 +109,7 @@ module Prdigest
|
|
|
133
109
|
return present_prose_failure(err, kind, message)
|
|
134
110
|
end
|
|
135
111
|
|
|
136
|
-
|
|
137
|
-
mode: parsed && parsed[:date] ? "explicit_date_replay" : "scheduled",
|
|
138
|
-
error_kind: "internal",
|
|
139
|
-
message: "unexpected CLI failure (#{e.class})"
|
|
140
|
-
)
|
|
141
|
-
present(result, json: parsed ? parsed[:json] : json_intent, out: out, err: err)
|
|
142
|
-
result.exit_code
|
|
112
|
+
present_cli_failure(err, "internal", "unexpected CLI failure (#{e.class})")
|
|
143
113
|
end
|
|
144
114
|
|
|
145
115
|
alias start invoke
|
|
@@ -242,13 +212,12 @@ module Prdigest
|
|
|
242
212
|
case parsed.fetch(:command)
|
|
243
213
|
when "facts" then :facts
|
|
244
214
|
when "prose" then parsed[:deliver] ? :prose_delivery : :prose
|
|
245
|
-
else :run
|
|
246
215
|
end
|
|
247
216
|
end
|
|
248
217
|
|
|
249
218
|
def present_facts_failure(out, error_kind, message)
|
|
250
219
|
out.puts JSON.generate(Facts.failure(error_kind: error_kind, message: message))
|
|
251
|
-
|
|
220
|
+
EXIT_CODES.fetch(error_kind.to_s, 1)
|
|
252
221
|
end
|
|
253
222
|
|
|
254
223
|
def present_prose(outcome, out:)
|
|
@@ -265,7 +234,12 @@ module Prdigest
|
|
|
265
234
|
|
|
266
235
|
def present_prose_failure(err, error_kind, message)
|
|
267
236
|
err.puts "prdigest: #{error_kind}: #{message}"
|
|
268
|
-
|
|
237
|
+
EXIT_CODES.fetch(error_kind.to_s, 1)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def present_cli_failure(err, error_kind, message)
|
|
241
|
+
err.puts "prdigest: #{error_kind}: #{message}"
|
|
242
|
+
EXIT_CODES.fetch(error_kind.to_s, 1)
|
|
269
243
|
end
|
|
270
244
|
|
|
271
245
|
def prose_error_kind(error)
|
|
@@ -293,39 +267,15 @@ module Prdigest
|
|
|
293
267
|
end
|
|
294
268
|
end
|
|
295
269
|
|
|
296
|
-
def present(result, json:, out:, err:)
|
|
297
|
-
if json
|
|
298
|
-
out.puts JSON.generate(result.to_h)
|
|
299
|
-
elsif result.exit_code.zero?
|
|
300
|
-
if result.status == "dry_run"
|
|
301
|
-
out.puts result.chunks.join("\n\n")
|
|
302
|
-
else
|
|
303
|
-
out.puts "prdigest: success; settled=#{result.settled_days.length} skipped=#{result.skipped_days.length}"
|
|
304
|
-
end
|
|
305
|
-
else
|
|
306
|
-
if result.status == "partial_failure"
|
|
307
|
-
err.puts "prdigest: progress; settled=#{human_dates(result.settled_days)} " \
|
|
308
|
-
"skipped=#{human_dates(result.skipped_days)} remaining=#{human_dates(result.remaining_days)}"
|
|
309
|
-
end
|
|
310
|
-
err.puts "prdigest: #{result.error[:kind]}: #{result.error[:message]}"
|
|
311
|
-
end
|
|
312
|
-
end
|
|
313
|
-
|
|
314
|
-
def human_dates(dates)
|
|
315
|
-
values = Array(dates)
|
|
316
|
-
values.empty? ? "none" : values.join(",")
|
|
317
|
-
end
|
|
318
|
-
|
|
319
270
|
def print_version(out)
|
|
320
271
|
out.puts "prdigest #{VERSION}"
|
|
321
272
|
0
|
|
322
273
|
end
|
|
323
274
|
|
|
324
275
|
def print_help(out)
|
|
325
|
-
out.puts "Usage: prdigest
|
|
326
|
-
out.puts " prdigest facts [--config PATH] [--date YYYY-MM-DD] [--repo owner/name]"
|
|
276
|
+
out.puts "Usage: prdigest facts [--config PATH] [--date YYYY-MM-DD] [--repo owner/name]"
|
|
327
277
|
out.puts " prdigest prose [--config PATH] [--date YYYY-MM-DD] [--repo owner/name] [--deliver]"
|
|
328
|
-
out.puts " prdigest
|
|
278
|
+
out.puts " prdigest version"
|
|
329
279
|
0
|
|
330
280
|
end
|
|
331
281
|
end
|
data/lib/prdigest/config.rb
CHANGED
|
@@ -28,7 +28,7 @@ module Prdigest
|
|
|
28
28
|
raise ConfigError, "config path is required (--config, PRDIGEST_CONFIG, or /etc/prdigest/config.yml)"
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
def self.load(path, capability: :
|
|
31
|
+
def self.load(path, capability: :facts)
|
|
32
32
|
path = File.expand_path(path)
|
|
33
33
|
raise ConfigError, "config not found: #{path}" unless File.file?(path)
|
|
34
34
|
|
|
@@ -143,35 +143,13 @@ module Prdigest
|
|
|
143
143
|
raw.dig("digest", "line_stats") != false
|
|
144
144
|
end
|
|
145
145
|
|
|
146
|
-
def send_empty?
|
|
147
|
-
raw.dig("digest", "send_empty") != false
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
def empty_message
|
|
151
|
-
raw.dig("digest", "empty_message") || "Merged PR digest — {date}\nTotal: 0 PRs"
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
def state_path
|
|
155
|
-
raw.dig("state", "path") || File.expand_path("~/.local/share/prdigest/state.json")
|
|
156
|
-
end
|
|
157
|
-
|
|
158
146
|
def delivery_state_path
|
|
159
|
-
raw.dig("state", "delivery_path") || File.
|
|
147
|
+
raw.dig("state", "delivery_path") || File.expand_path("~/.local/share/prdigest/deliveries")
|
|
160
148
|
end
|
|
161
149
|
|
|
162
|
-
def
|
|
163
|
-
raw.dig("schedule", "cron") || "5 9 * * *"
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def max_catchup_days
|
|
167
|
-
Integer(raw.dig("schedule", "max_catchup_days") || 7)
|
|
168
|
-
rescue TypeError, ArgumentError
|
|
169
|
-
raise ConfigError, "schedule.max_catchup_days must be an integer from 1 to 30"
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
def validate!(capability: :run)
|
|
150
|
+
def validate!(capability: :facts)
|
|
173
151
|
capability = capability.to_sym
|
|
174
|
-
unless %i[facts
|
|
152
|
+
unless %i[facts prose prose_delivery].include?(capability)
|
|
175
153
|
raise ArgumentError, "unknown configuration capability: #{capability}"
|
|
176
154
|
end
|
|
177
155
|
|
|
@@ -180,9 +158,6 @@ module Prdigest
|
|
|
180
158
|
rescue TZInfo::InvalidTimezoneIdentifier
|
|
181
159
|
raise ConfigError, "timezone must be a resolvable IANA identifier"
|
|
182
160
|
end
|
|
183
|
-
unless (1..30).cover?(max_catchup_days)
|
|
184
|
-
raise ConfigError, "schedule.max_catchup_days must be from 1 to 30"
|
|
185
|
-
end
|
|
186
161
|
repos
|
|
187
162
|
return self if capability == :facts
|
|
188
163
|
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "cgi"
|
|
4
|
-
|
|
5
3
|
module Prdigest
|
|
6
4
|
class ProseRenderer
|
|
5
|
+
Output = Data.define(:chunks, :outcome)
|
|
7
6
|
DISALLOWED_CONTROL_CHARACTERS = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/
|
|
8
7
|
|
|
9
8
|
def initialize(limit: 4_096)
|
|
@@ -23,10 +22,8 @@ module Prdigest
|
|
|
23
22
|
)
|
|
24
23
|
end
|
|
25
24
|
|
|
26
|
-
chunks = text.each_char.each_slice(@limit).map
|
|
27
|
-
|
|
28
|
-
end
|
|
29
|
-
Renderer::Output.new(chunks.freeze, "rendered")
|
|
25
|
+
chunks = text.each_char.each_slice(@limit).map { |characters| characters.join.freeze }
|
|
26
|
+
Output.new(chunks.freeze, "rendered")
|
|
30
27
|
rescue RenderError
|
|
31
28
|
raise
|
|
32
29
|
rescue StandardError => e
|
data/lib/prdigest/telegram.rb
CHANGED
data/lib/prdigest/version.rb
CHANGED
data/lib/prdigest.rb
CHANGED
|
@@ -48,13 +48,8 @@ require_relative "prdigest/collector"
|
|
|
48
48
|
require_relative "prdigest/facts"
|
|
49
49
|
require_relative "prdigest/facts_runner"
|
|
50
50
|
require_relative "prdigest/openai_compatible"
|
|
51
|
-
require_relative "prdigest/renderer"
|
|
52
51
|
require_relative "prdigest/prose_renderer"
|
|
53
52
|
require_relative "prdigest/delivery_checkpoint_store"
|
|
54
53
|
require_relative "prdigest/telegram"
|
|
55
54
|
require_relative "prdigest/prose_runner"
|
|
56
|
-
require_relative "prdigest/result"
|
|
57
|
-
require_relative "prdigest/schedule"
|
|
58
|
-
require_relative "prdigest/state"
|
|
59
|
-
require_relative "prdigest/runner"
|
|
60
55
|
require_relative "prdigest/cli"
|
|
@@ -8,7 +8,7 @@ Type=oneshot
|
|
|
8
8
|
User=prdigest
|
|
9
9
|
Group=prdigest
|
|
10
10
|
EnvironmentFile=/etc/prdigest/.env
|
|
11
|
-
ExecStart=/usr/local/bin/prdigest
|
|
11
|
+
ExecStart=/usr/local/bin/prdigest prose --config /etc/prdigest/config.yml --deliver
|
|
12
12
|
StateDirectory=prdigest
|
|
13
13
|
StateDirectoryMode=0700
|
|
14
14
|
UMask=0077
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: prdigest
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ivan Kuznetsov
|
|
@@ -127,8 +127,8 @@ dependencies:
|
|
|
127
127
|
- - "~>"
|
|
128
128
|
- !ruby/object:Gem::Version
|
|
129
129
|
version: '3.25'
|
|
130
|
-
description: Config-driven GitHub merged-PR facts
|
|
131
|
-
|
|
130
|
+
description: Config-driven GitHub merged-PR facts for agents plus OpenAI-compatible
|
|
131
|
+
prose for stdout or checkpointed Telegram delivery.
|
|
132
132
|
email:
|
|
133
133
|
- ivan@ikuznetsov.com
|
|
134
134
|
executables:
|
|
@@ -155,11 +155,6 @@ files:
|
|
|
155
155
|
- lib/prdigest/openai_compatible.rb
|
|
156
156
|
- lib/prdigest/prose_renderer.rb
|
|
157
157
|
- lib/prdigest/prose_runner.rb
|
|
158
|
-
- lib/prdigest/renderer.rb
|
|
159
|
-
- lib/prdigest/result.rb
|
|
160
|
-
- lib/prdigest/runner.rb
|
|
161
|
-
- lib/prdigest/schedule.rb
|
|
162
|
-
- lib/prdigest/state.rb
|
|
163
158
|
- lib/prdigest/telegram.rb
|
|
164
159
|
- lib/prdigest/version.rb
|
|
165
160
|
- scripts/systemd/prdigest.service
|
|
@@ -188,5 +183,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
188
183
|
requirements: []
|
|
189
184
|
rubygems_version: 3.6.9
|
|
190
185
|
specification_version: 4
|
|
191
|
-
summary:
|
|
186
|
+
summary: Merged-PR facts for agents and provider-written prose
|
|
192
187
|
test_files: []
|
data/lib/prdigest/renderer.rb
DELETED
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "cgi"
|
|
4
|
-
|
|
5
|
-
module Prdigest
|
|
6
|
-
class Renderer
|
|
7
|
-
Output = Data.define(:chunks, :outcome)
|
|
8
|
-
SUPPORTED_TAG = %r{</?(?:b|a)(?:\s+href="[^"]*")?>}.freeze
|
|
9
|
-
|
|
10
|
-
def self.parsed_length(html)
|
|
11
|
-
CGI.unescapeHTML(html.gsub(SUPPORTED_TAG, "")).length
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def initialize(send_empty:, empty_message:, limit: 4_096)
|
|
15
|
-
@send_empty = send_empty
|
|
16
|
-
@empty_message = empty_message.to_s
|
|
17
|
-
@limit = Integer(limit)
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def render(digest)
|
|
21
|
-
return render_empty(digest) if digest.total_prs.zero?
|
|
22
|
-
|
|
23
|
-
pack(digest)
|
|
24
|
-
rescue RenderError
|
|
25
|
-
raise
|
|
26
|
-
rescue StandardError => e
|
|
27
|
-
raise RenderError, "digest rendering failed: #{e.class}"
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
private
|
|
31
|
-
|
|
32
|
-
def render_empty(digest)
|
|
33
|
-
return Output.new([].freeze, "suppressed_empty") unless @send_empty
|
|
34
|
-
|
|
35
|
-
text = escape(@empty_message.gsub("{date}", digest.date.iso8601))
|
|
36
|
-
raise RenderError, "configured empty message exceeds Telegram limit" unless fits_text?(text)
|
|
37
|
-
Output.new([text.freeze].freeze, "rendered")
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def pack(digest)
|
|
41
|
-
chunks = []
|
|
42
|
-
heading = "<b>Merged PR digest — #{digest.date.iso8601}</b>"
|
|
43
|
-
continuation = "<b>Merged PR digest — #{digest.date.iso8601} (cont.)</b>"
|
|
44
|
-
current = [heading]
|
|
45
|
-
|
|
46
|
-
digest.repositories.each do |section|
|
|
47
|
-
next if section.pull_requests.empty?
|
|
48
|
-
current = add_section(chunks, current, continuation, section)
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
footer = render_footer(digest)
|
|
52
|
-
unless fits_parts?(current + [footer])
|
|
53
|
-
flush!(chunks, current)
|
|
54
|
-
current = [continuation, footer]
|
|
55
|
-
raise RenderError, "digest footer exceeds Telegram limit" unless fits_parts?(current)
|
|
56
|
-
else
|
|
57
|
-
current << footer
|
|
58
|
-
end
|
|
59
|
-
flush!(chunks, current)
|
|
60
|
-
Output.new(chunks.map(&:freeze).freeze, "rendered")
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def add_section(chunks, current, continuation, section)
|
|
64
|
-
header = "<b>#{escape(section.name)}</b>"
|
|
65
|
-
entries = section.pull_requests.map { |pull| render_pull(pull) }
|
|
66
|
-
whole = ([header] + entries).join("\n")
|
|
67
|
-
if fits_parts?(current + [whole])
|
|
68
|
-
current << whole
|
|
69
|
-
return current
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
if current.length > 1
|
|
73
|
-
flush!(chunks, current)
|
|
74
|
-
current = [continuation]
|
|
75
|
-
if fits_parts?(current + [whole])
|
|
76
|
-
current << whole
|
|
77
|
-
return current
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
section_part = header
|
|
82
|
-
entries.each do |entry|
|
|
83
|
-
proposed = "#{section_part}\n#{entry}"
|
|
84
|
-
if fits_parts?(current + [proposed])
|
|
85
|
-
section_part = proposed
|
|
86
|
-
next
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
if section_part == header
|
|
90
|
-
raise RenderError, "pull request entry exceeds Telegram limit"
|
|
91
|
-
end
|
|
92
|
-
current << section_part
|
|
93
|
-
flush!(chunks, current)
|
|
94
|
-
current = [continuation]
|
|
95
|
-
section_part = "<b>#{escape(section.name)} (cont.)</b>\n#{entry}"
|
|
96
|
-
raise RenderError, "pull request entry exceeds Telegram limit" unless fits_parts?(current + [section_part])
|
|
97
|
-
end
|
|
98
|
-
current << section_part
|
|
99
|
-
current
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def render_pull(pull)
|
|
103
|
-
number_and_title = "##{pull.number} #{pull.title}"
|
|
104
|
-
"• <a href=\"#{escape(pull.url)}\">#{escape(number_and_title)}</a> — @#{escape(pull.author)}"
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def render_footer(digest)
|
|
108
|
-
label = digest.total_prs == 1 ? "PR" : "PRs"
|
|
109
|
-
footer = "<b>Total:</b> #{digest.total_prs} #{label}"
|
|
110
|
-
if digest.line_stats
|
|
111
|
-
footer += " · +#{digest.total_additions}/−#{digest.total_deletions} · #{digest.total_commits} commits"
|
|
112
|
-
end
|
|
113
|
-
footer
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def flush!(chunks, parts)
|
|
117
|
-
text = parts.join("\n\n")
|
|
118
|
-
raise RenderError, "rendered chunk exceeds Telegram limit" unless fits_text?(text)
|
|
119
|
-
chunks << text
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def fits_parts?(parts)
|
|
123
|
-
fits_text?(parts.join("\n\n"))
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def fits_text?(text)
|
|
127
|
-
self.class.parsed_length(text) <= @limit
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def escape(value)
|
|
131
|
-
CGI.escapeHTML(value.to_s)
|
|
132
|
-
end
|
|
133
|
-
end
|
|
134
|
-
end
|
data/lib/prdigest/result.rb
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Prdigest
|
|
4
|
-
class Result
|
|
5
|
-
SCHEMA = "prdigest-result"
|
|
6
|
-
SCHEMA_VERSION = 1
|
|
7
|
-
REQUIRED_FIELDS = %i[
|
|
8
|
-
status mode requested_days settled_days skipped_days failed_date remaining_days error
|
|
9
|
-
].freeze
|
|
10
|
-
EXIT_CODES = {
|
|
11
|
-
"config" => 2, "cli" => 2, "refusal" => 2,
|
|
12
|
-
"github" => 3,
|
|
13
|
-
"telegram" => 4, "telegram_refused" => 4, "telegram_permanent" => 4,
|
|
14
|
-
"telegram_ambiguous" => 4, "delivery_checkpoint" => 4,
|
|
15
|
-
"delivery_checkpoint_permanent" => 4,
|
|
16
|
-
"state" => 5,
|
|
17
|
-
"provider" => 7, "provider_ambiguous" => 7
|
|
18
|
-
}.freeze
|
|
19
|
-
|
|
20
|
-
attr_reader(*REQUIRED_FIELDS, :chunks, :delivery)
|
|
21
|
-
|
|
22
|
-
def initialize(status:, mode:, requested_days: [], settled_days: [], skipped_days: [],
|
|
23
|
-
failed_date: nil, remaining_days: [], error: nil, chunks: [], delivery: nil)
|
|
24
|
-
@status = status.to_s.freeze
|
|
25
|
-
@mode = mode.to_s.freeze
|
|
26
|
-
@requested_days = Array(requested_days).freeze
|
|
27
|
-
@settled_days = Array(settled_days).freeze
|
|
28
|
-
@skipped_days = Array(skipped_days).freeze
|
|
29
|
-
@failed_date = failed_date
|
|
30
|
-
@remaining_days = Array(remaining_days).freeze
|
|
31
|
-
@error = error&.transform_keys(&:to_sym)&.freeze
|
|
32
|
-
@chunks = Array(chunks).map(&:to_s).freeze
|
|
33
|
-
@delivery = delivery&.transform_keys(&:to_sym)&.freeze
|
|
34
|
-
freeze
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def self.failure(mode:, error_kind:, message:, requested_days: [], settled_days: [], skipped_days: [],
|
|
38
|
-
failed_date: nil, remaining_days: [], chunks: [], delivery: nil)
|
|
39
|
-
progress = !settled_days.empty? || !skipped_days.empty?
|
|
40
|
-
new(
|
|
41
|
-
status: progress ? "partial_failure" : "failure",
|
|
42
|
-
mode: mode,
|
|
43
|
-
requested_days: requested_days,
|
|
44
|
-
settled_days: settled_days,
|
|
45
|
-
skipped_days: skipped_days,
|
|
46
|
-
failed_date: failed_date,
|
|
47
|
-
remaining_days: remaining_days,
|
|
48
|
-
error: { kind: error_kind.to_s, message: message.to_s },
|
|
49
|
-
chunks: chunks,
|
|
50
|
-
delivery: delivery
|
|
51
|
-
)
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def exit_code
|
|
55
|
-
return 0 if %w[success dry_run].include?(status)
|
|
56
|
-
return 6 if status == "partial_failure"
|
|
57
|
-
EXIT_CODES.fetch(error && error[:kind], 1)
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def to_h
|
|
61
|
-
{
|
|
62
|
-
schema: SCHEMA,
|
|
63
|
-
schema_version: SCHEMA_VERSION,
|
|
64
|
-
status: status,
|
|
65
|
-
mode: mode,
|
|
66
|
-
requested_days: serialize_dates(requested_days),
|
|
67
|
-
settled_days: serialize_dates(settled_days),
|
|
68
|
-
skipped_days: serialize_dates(skipped_days),
|
|
69
|
-
failed_date: failed_date&.to_s,
|
|
70
|
-
remaining_days: serialize_dates(remaining_days),
|
|
71
|
-
error: error,
|
|
72
|
-
chunks: chunks,
|
|
73
|
-
delivery: delivery
|
|
74
|
-
}
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
private
|
|
78
|
-
|
|
79
|
-
def serialize_dates(values)
|
|
80
|
-
values.map(&:to_s)
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
end
|
data/lib/prdigest/runner.rb
DELETED
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "date"
|
|
4
|
-
|
|
5
|
-
module Prdigest
|
|
6
|
-
class Runner
|
|
7
|
-
def initialize(config:, date: nil, dry_run: false, clock: nil, state_factory: nil,
|
|
8
|
-
github: nil, renderer: nil, telegram_factory: nil, repositories: nil, env: ENV)
|
|
9
|
-
@config = config
|
|
10
|
-
@date = date && Date.iso8601(date.to_s)
|
|
11
|
-
@dry_run = dry_run
|
|
12
|
-
@clock = clock || Clock.new(timezone: config.timezone)
|
|
13
|
-
@state_factory = state_factory || -> { State.new(path: config.state_path, timezone: config.timezone) }
|
|
14
|
-
@env = env
|
|
15
|
-
@repositories = repositories || config.repos
|
|
16
|
-
@collector = Collector.new(
|
|
17
|
-
clock: @clock,
|
|
18
|
-
github: github || GitHub.new(token: config.github_token(env)),
|
|
19
|
-
repositories: @repositories,
|
|
20
|
-
line_stats: config.line_stats?
|
|
21
|
-
)
|
|
22
|
-
@renderer = renderer || Renderer.new(send_empty: config.send_empty?, empty_message: config.empty_message)
|
|
23
|
-
@delivery_checkpoint_store_factory = lambda {
|
|
24
|
-
DeliveryCheckpointStore.new(root: config.delivery_state_path)
|
|
25
|
-
}
|
|
26
|
-
@telegram_factory = telegram_factory || lambda {
|
|
27
|
-
Telegram.new(
|
|
28
|
-
token: config.telegram_token(env),
|
|
29
|
-
chat_id: config.chat_id,
|
|
30
|
-
allowlist: config.chat_id_allowlist
|
|
31
|
-
)
|
|
32
|
-
}
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def call
|
|
36
|
-
mode = @date ? "explicit_date_replay" : "scheduled"
|
|
37
|
-
requested = []
|
|
38
|
-
settled = []
|
|
39
|
-
skipped = []
|
|
40
|
-
chunks = []
|
|
41
|
-
failed_date = nil
|
|
42
|
-
delivery = nil
|
|
43
|
-
state = nil
|
|
44
|
-
skip_audit = nil
|
|
45
|
-
|
|
46
|
-
if @dry_run || @date
|
|
47
|
-
requested = [@date || @clock.yesterday]
|
|
48
|
-
else
|
|
49
|
-
state = @state_factory.call
|
|
50
|
-
yesterday = @clock.yesterday
|
|
51
|
-
record = state.read(yesterday: yesterday)
|
|
52
|
-
skip_audit = record.last_skip
|
|
53
|
-
skipped.concat(expand_skip(skip_audit)) if skip_audit && skip_audit[:notice_pending]
|
|
54
|
-
schedule = Schedule.new(
|
|
55
|
-
yesterday: yesterday,
|
|
56
|
-
last_digested_date: record.last_digested_date,
|
|
57
|
-
max_catchup_days: @config.max_catchup_days
|
|
58
|
-
).call
|
|
59
|
-
requested = schedule.requested_days
|
|
60
|
-
unless schedule.skipped_days.empty?
|
|
61
|
-
skip_audit = merge_skip_audit(skip_audit, schedule.skipped_days)
|
|
62
|
-
state.write(last_digested_date: schedule.skipped_days.last, last_skip: skip_audit)
|
|
63
|
-
skipped |= schedule.skipped_days
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
telegram = nil
|
|
68
|
-
requested.each do |date|
|
|
69
|
-
failed_date = date
|
|
70
|
-
digest = @collector.call(date: date)
|
|
71
|
-
rendered = @renderer.render(digest)
|
|
72
|
-
if @dry_run
|
|
73
|
-
chunks.concat(rendered.chunks)
|
|
74
|
-
next
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
unless rendered.chunks.empty?
|
|
78
|
-
telegram ||= @telegram_factory.call
|
|
79
|
-
delivery = telegram.deliver(
|
|
80
|
-
rendered.chunks,
|
|
81
|
-
digest_date: date,
|
|
82
|
-
checkpoint_store: @delivery_checkpoint_store_factory.call,
|
|
83
|
-
scope: @repositories
|
|
84
|
-
)
|
|
85
|
-
end
|
|
86
|
-
if state
|
|
87
|
-
if skip_audit
|
|
88
|
-
skip_audit = skip_audit.merge(notice_pending: false)
|
|
89
|
-
end
|
|
90
|
-
state.write(last_digested_date: date, last_skip: skip_audit)
|
|
91
|
-
end
|
|
92
|
-
settled << date
|
|
93
|
-
failed_date = nil
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
Result.new(
|
|
97
|
-
status: @dry_run ? "dry_run" : "success",
|
|
98
|
-
mode: mode,
|
|
99
|
-
requested_days: requested,
|
|
100
|
-
settled_days: settled,
|
|
101
|
-
skipped_days: skipped,
|
|
102
|
-
chunks: chunks,
|
|
103
|
-
delivery: delivery
|
|
104
|
-
)
|
|
105
|
-
rescue StandardError => e
|
|
106
|
-
Result.failure(
|
|
107
|
-
mode: mode || (@date ? "explicit_date_replay" : "scheduled"),
|
|
108
|
-
error_kind: error_kind(e),
|
|
109
|
-
message: safe_message(e),
|
|
110
|
-
requested_days: requested || [],
|
|
111
|
-
settled_days: settled || [],
|
|
112
|
-
skipped_days: skipped || [],
|
|
113
|
-
failed_date: failed_date,
|
|
114
|
-
remaining_days: remaining_days(requested || [], failed_date),
|
|
115
|
-
chunks: chunks || [],
|
|
116
|
-
delivery: e.is_a?(SendError) ? e.delivery : delivery
|
|
117
|
-
)
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def inspect
|
|
121
|
-
"#<#{self.class} mode=#{@date ? "explicit_date_replay" : "scheduled"} " \
|
|
122
|
-
"dry_run=#{@dry_run} credentials=[REDACTED]>"
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
alias to_s inspect
|
|
126
|
-
|
|
127
|
-
private
|
|
128
|
-
|
|
129
|
-
def expand_skip(audit)
|
|
130
|
-
return [] unless audit
|
|
131
|
-
(audit.fetch(:start_date)..audit.fetch(:end_date)).to_a
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def merge_skip_audit(existing, dates)
|
|
135
|
-
start_date = if existing && existing[:notice_pending]
|
|
136
|
-
[existing[:start_date], dates.first].min
|
|
137
|
-
else
|
|
138
|
-
dates.first
|
|
139
|
-
end
|
|
140
|
-
{ start_date: start_date, end_date: dates.last, notice_pending: true }
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def remaining_days(requested, failed_date)
|
|
144
|
-
return requested unless failed_date
|
|
145
|
-
index = requested.index(failed_date)
|
|
146
|
-
index ? requested.drop(index) : [failed_date]
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
def error_kind(error)
|
|
150
|
-
case error
|
|
151
|
-
when ConfigError then "config"
|
|
152
|
-
when FetchError then error.kind
|
|
153
|
-
when SendError then error.kind
|
|
154
|
-
when StateError then "state"
|
|
155
|
-
when RenderError then error.kind
|
|
156
|
-
else "internal"
|
|
157
|
-
end
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
def safe_message(error)
|
|
161
|
-
message = error.is_a?(Error) ? error.message : "unexpected failure (#{error.class})"
|
|
162
|
-
secrets = []
|
|
163
|
-
secrets << @config.github_token(@env) if @config.respond_to?(:github_token)
|
|
164
|
-
secrets << @config.telegram_token(@env) if @config.respond_to?(:telegram_token)
|
|
165
|
-
secrets.compact.reject(&:empty?).reduce(message.dup) { |safe, secret| safe.gsub(secret, "[REDACTED]") }
|
|
166
|
-
end
|
|
167
|
-
end
|
|
168
|
-
end
|
data/lib/prdigest/schedule.rb
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "date"
|
|
4
|
-
|
|
5
|
-
module Prdigest
|
|
6
|
-
class Schedule
|
|
7
|
-
Result = Data.define(:skipped_days, :requested_days)
|
|
8
|
-
|
|
9
|
-
def initialize(yesterday:, last_digested_date:, max_catchup_days:)
|
|
10
|
-
@yesterday = Date.iso8601(yesterday.to_s)
|
|
11
|
-
@last_digested_date = last_digested_date && Date.iso8601(last_digested_date.to_s)
|
|
12
|
-
@max_catchup_days = Integer(max_catchup_days)
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def call
|
|
16
|
-
raise ArgumentError, "max_catchup_days must be from 1 to 30" unless (1..30).cover?(@max_catchup_days)
|
|
17
|
-
raise ArgumentError, "last digested date is in the future" if @last_digested_date && @last_digested_date > @yesterday
|
|
18
|
-
|
|
19
|
-
owed = if @last_digested_date.nil?
|
|
20
|
-
[@yesterday]
|
|
21
|
-
elsif @last_digested_date == @yesterday
|
|
22
|
-
[]
|
|
23
|
-
else
|
|
24
|
-
((@last_digested_date + 1)..@yesterday).to_a
|
|
25
|
-
end
|
|
26
|
-
split = [owed.length - @max_catchup_days, 0].max
|
|
27
|
-
Result.new(owed.first(split).freeze, owed.drop(split).freeze)
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
data/lib/prdigest/state.rb
DELETED
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "date"
|
|
4
|
-
require "fileutils"
|
|
5
|
-
require "json"
|
|
6
|
-
require "securerandom"
|
|
7
|
-
|
|
8
|
-
module Prdigest
|
|
9
|
-
class State
|
|
10
|
-
VERSION = 1
|
|
11
|
-
Record = Data.define(:last_digested_date, :last_skip)
|
|
12
|
-
|
|
13
|
-
def initialize(path:, timezone:, rename: File.method(:rename), directory_sync: nil)
|
|
14
|
-
@path = File.expand_path(path)
|
|
15
|
-
@timezone = timezone
|
|
16
|
-
@rename = rename
|
|
17
|
-
@directory_sync = directory_sync || method(:sync_directory)
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def read(yesterday: nil)
|
|
21
|
-
return Record.new(nil, nil) unless File.exist?(@path)
|
|
22
|
-
|
|
23
|
-
payload = JSON.parse(File.binread(@path))
|
|
24
|
-
validate_payload!(payload, yesterday: yesterday)
|
|
25
|
-
rescue StateError
|
|
26
|
-
raise
|
|
27
|
-
rescue StandardError => e
|
|
28
|
-
raise StateError, "cannot read state: #{e.class}"
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def write(last_digested_date:, last_skip: nil)
|
|
32
|
-
directory = File.dirname(@path)
|
|
33
|
-
FileUtils.mkdir_p(directory, mode: 0o700)
|
|
34
|
-
temp_path = File.join(directory, ".#{File.basename(@path)}.tmp-#{SecureRandom.hex(8)}")
|
|
35
|
-
payload = JSON.generate(serialize(last_digested_date, last_skip)) << "\n"
|
|
36
|
-
|
|
37
|
-
File.open(temp_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
38
|
-
file.write(payload)
|
|
39
|
-
file.chmod(0o600)
|
|
40
|
-
file.flush
|
|
41
|
-
file.fsync
|
|
42
|
-
end
|
|
43
|
-
rollback_path = preserve_previous_checkpoint(directory)
|
|
44
|
-
@rename.call(temp_path, @path)
|
|
45
|
-
begin
|
|
46
|
-
@directory_sync.call(directory)
|
|
47
|
-
rescue StandardError
|
|
48
|
-
restore_previous_checkpoint(rollback_path)
|
|
49
|
-
raise
|
|
50
|
-
end
|
|
51
|
-
Record.new(Date.iso8601(last_digested_date.to_s), normalize_skip(last_skip))
|
|
52
|
-
rescue StandardError => e
|
|
53
|
-
raise StateError, "cannot write state: #{e.class}"
|
|
54
|
-
ensure
|
|
55
|
-
File.unlink(temp_path) if defined?(temp_path) && temp_path && File.exist?(temp_path)
|
|
56
|
-
discard_rollback(rollback_path) if defined?(rollback_path) && rollback_path
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
private
|
|
60
|
-
|
|
61
|
-
def sync_directory(directory)
|
|
62
|
-
File.open(directory, File::RDONLY) { |dir| dir.fsync }
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def preserve_previous_checkpoint(directory)
|
|
66
|
-
rollback_path = File.join(directory, ".#{File.basename(@path)}.rollback-#{SecureRandom.hex(8)}")
|
|
67
|
-
File.link(@path, rollback_path)
|
|
68
|
-
rollback_path
|
|
69
|
-
rescue Errno::ENOENT
|
|
70
|
-
nil
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def restore_previous_checkpoint(rollback_path)
|
|
74
|
-
if rollback_path
|
|
75
|
-
File.rename(rollback_path, @path)
|
|
76
|
-
else
|
|
77
|
-
File.unlink(@path)
|
|
78
|
-
end
|
|
79
|
-
rescue Errno::ENOENT
|
|
80
|
-
raise if rollback_path
|
|
81
|
-
nil
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def discard_rollback(rollback_path)
|
|
85
|
-
FileUtils.rm_f(rollback_path) if rollback_path
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def validate_payload!(payload, yesterday:)
|
|
89
|
-
raise StateError, "state root must be an object" unless payload.is_a?(Hash)
|
|
90
|
-
raise StateError, "unsupported state version" unless payload["version"] == VERSION
|
|
91
|
-
raise StateError, "state timezone does not match config" unless payload["timezone"] == @timezone
|
|
92
|
-
raise StateError, "state last_digested_date is required" unless payload.key?("last_digested_date")
|
|
93
|
-
|
|
94
|
-
value = payload["last_digested_date"]
|
|
95
|
-
last_date = Date.iso8601(value)
|
|
96
|
-
raise StateError, "state date is in the future" if yesterday && last_date && last_date > Date.iso8601(yesterday.to_s)
|
|
97
|
-
|
|
98
|
-
skip = parse_skip(payload["last_skip"])
|
|
99
|
-
if skip && (skip[:start_date] > skip[:end_date] || skip[:end_date] > last_date)
|
|
100
|
-
raise StateError, "state last_skip range is invalid"
|
|
101
|
-
end
|
|
102
|
-
Record.new(last_date, skip)
|
|
103
|
-
rescue Date::Error, TypeError
|
|
104
|
-
raise StateError, "state contains an invalid date"
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def parse_skip(value)
|
|
108
|
-
return nil if value.nil?
|
|
109
|
-
raise StateError, "state last_skip is invalid" unless value.is_a?(Hash)
|
|
110
|
-
|
|
111
|
-
pending = value.fetch("notice_pending")
|
|
112
|
-
raise StateError, "state last_skip is invalid" unless pending == true || pending == false
|
|
113
|
-
{
|
|
114
|
-
start_date: Date.iso8601(value.fetch("start_date")),
|
|
115
|
-
end_date: Date.iso8601(value.fetch("end_date")),
|
|
116
|
-
notice_pending: pending
|
|
117
|
-
}
|
|
118
|
-
rescue KeyError
|
|
119
|
-
raise StateError, "state last_skip is invalid"
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def normalize_skip(skip)
|
|
123
|
-
return nil unless skip
|
|
124
|
-
{
|
|
125
|
-
start_date: Date.iso8601(skip.fetch(:start_date).to_s),
|
|
126
|
-
end_date: Date.iso8601(skip.fetch(:end_date).to_s),
|
|
127
|
-
notice_pending: skip.fetch(:notice_pending) == true
|
|
128
|
-
}
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def serialize(last_date, last_skip)
|
|
132
|
-
payload = {
|
|
133
|
-
version: VERSION,
|
|
134
|
-
timezone: @timezone,
|
|
135
|
-
last_digested_date: Date.iso8601(last_date.to_s).iso8601
|
|
136
|
-
}
|
|
137
|
-
if (skip = normalize_skip(last_skip))
|
|
138
|
-
payload[:last_skip] = {
|
|
139
|
-
start_date: skip[:start_date].iso8601,
|
|
140
|
-
end_date: skip[:end_date].iso8601,
|
|
141
|
-
notice_pending: skip[:notice_pending]
|
|
142
|
-
}
|
|
143
|
-
end
|
|
144
|
-
payload
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
|
-
end
|