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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 409ae44cab5ad005c5dcb413632295fdd3aae2e8d6383852b71863f8fc484d15
4
- data.tar.gz: 7ae70339af9e94123d83795362bfde4c07ee57d94bf447123fe4ce305f9117b4
3
+ metadata.gz: 9ff60040fe7091056de1781ae7154e758145cf7216ec1b664adc661cd479c96f
4
+ data.tar.gz: aa87baccb18ac37b7398c83e5d312187e3d697fa8fa9e161c6f3b930cef73ae4
5
5
  SHA512:
6
- metadata.gz: f5c7c0e265e13c87e2a625c3ee8fd10cde644b99297277b0dd5fd1f2f4a980ea0d7d81f67a63021b9741af8811b8903af47595c9713b507dd36fb749118e4489
7
- data.tar.gz: 4c787a605900e7f93722a72929708cf5032e35d1aff08a2134d41430d6e17b58f578ef20d965141391c87943eefbe3e99781a99cfcf72f08e0b35257c90d1d50
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 source of truth for merged pull requests. Three ways to present it.</strong></p>
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
- Collect merged PRs once, then deliver a deterministic Telegram digest, emit<br>
16
- stable JSON for an agent, or generate optional prose through an
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
- - **Deterministic by default** — repository order, pull-request order, and JSON
49
- shape are stable.
50
- - **AI stays optional** — neither `run` nor `facts` configures or contacts a
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
- | Reliable scheduled digest | `prdigest run` | Deterministic Telegram HTML | Reads/writes schedule and delivery state |
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
- | 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 |
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=... # run or prose --deliver only
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 scheduler state, Telegram, or prose provider
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.2.0.gem
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`; it does not ambiently
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
- escaped and chunked payload is stored under `state.delivery_path/prose` before
279
- the first Telegram request. Provider output containing terminal control
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 deterministic prose or another provider.
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 render the complete day.
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
- Prose delivery uses its own checkpoint namespace. Once a payload exists, retry
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>Scheduling, replay, and state details</strong></summary>
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. A day settles only after every chunk succeeds, an enabled
308
- empty message succeeds, or an empty message is intentionally suppressed.
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
- When backlog exceeds the configured cap, PRDigest durably skips the oldest
324
- prefix and processes only the newest window. For a timezone migration, stop the
325
- timer, preserve and move aside the old state, change timezone, explicitly
326
- replay any required dates, then restart the timer.
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
- GitHub does not guarantee search-index freshness. Keep the host timezone aligned
329
- with the digest timezone and use `--date YYYY-MM-DD` for a delayed merge found
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
- a known-good state backup, then restart. Replay omitted dates explicitly; never
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 or dry-run | — |
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` | State failure | Path, owner, mode, JSON version/date/timezone |
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. Concurrent
437
- scheduled runs are unsupported; the systemd oneshot is the normal coordinator.
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. Neither mode is ambient: `prdigest run` and
39
- `prdigest facts` never contact the standalone prose provider.
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 the same secret-free mode-`0600` files and mode-`0700` directories as
49
- deterministic delivery, under a separate `prose` namespace. Protect, retain, and
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
 
@@ -1,12 +1,6 @@
1
- # prdigest — multi-repo merged-PR daily digest (Hive-independent)
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`; `run` and `facts` never contact this endpoint.
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
- path: /var/lib/prdigest/state.json
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
- desc "run", "Build and send (or dry-run) a merged-PR digest for one local day"
12
- def run_cmd; end
13
- map "run" => :run_cmd
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", runner_factory: nil,
30
- facts_runner_factory: nil, prose_runner_factory: nil)
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 run serve].include?(parsed[:command])
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
- result = Result.failure(mode: "scheduled", error_kind: "cli", message: e.message)
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
- result = Result.failure(mode: parsed && parsed[:date] ? "explicit_date_replay" : "scheduled", error_kind: "config", message: e.message)
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
- result = Result.failure(
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
- Result::EXIT_CODES.fetch(error_kind.to_s, 1)
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
- Result::EXIT_CODES.fetch(error_kind.to_s, 1)
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 run [--config PATH] [--date YYYY-MM-DD] [--repo owner/name] [--dry-run] [--json]"
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 serve | version"
278
+ out.puts " prdigest version"
329
279
  0
330
280
  end
331
281
  end
@@ -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: :run)
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.join(File.dirname(state_path), "deliveries")
147
+ raw.dig("state", "delivery_path") || File.expand_path("~/.local/share/prdigest/deliveries")
160
148
  end
161
149
 
162
- def schedule_cron
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 run prose prose_delivery].include?(capability)
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 do |characters|
27
- CGI.escapeHTML(characters.join).freeze
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
@@ -109,7 +109,6 @@ module Prdigest
109
109
  request.body = JSON.generate(
110
110
  chat_id: @chat_id,
111
111
  text: text,
112
- parse_mode: "HTML",
113
112
  link_preview_options: { is_disabled: true }
114
113
  )
115
114
  request
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Prdigest
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
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 run --config /etc/prdigest/config.yml
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.2.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 with deterministic Telegram delivery
131
- and optional OpenAI-compatible prose. Hive-independent and secret-safe.
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: Deterministic multi-repo merged-PR facts and digests
186
+ summary: Merged-PR facts for agents and provider-written prose
192
187
  test_files: []
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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