prdigest 0.1.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 +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE +21 -0
- data/README.md +226 -0
- data/SECURITY.md +28 -0
- data/configs/config.example.yml +32 -0
- data/exe/prdigest +6 -0
- data/lib/prdigest/cli.rb +169 -0
- data/lib/prdigest/clock.rb +50 -0
- data/lib/prdigest/config.rb +136 -0
- data/lib/prdigest/delivery_checkpoint_store.rb +272 -0
- data/lib/prdigest/digest.rb +60 -0
- data/lib/prdigest/github.rb +199 -0
- data/lib/prdigest/renderer.rb +134 -0
- data/lib/prdigest/result.rb +82 -0
- data/lib/prdigest/runner.rb +173 -0
- data/lib/prdigest/schedule.rb +30 -0
- data/lib/prdigest/state.rb +147 -0
- data/lib/prdigest/telegram.rb +180 -0
- data/lib/prdigest/version.rb +5 -0
- data/lib/prdigest.rb +46 -0
- data/scripts/systemd/prdigest.service +27 -0
- data/scripts/systemd/prdigest.timer +12 -0
- metadata +186 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 264ab3685453725051fd4801627cc9eaf917bfa44f6ec9eee58d375168576761
|
|
4
|
+
data.tar.gz: 6fad72d4c5aaf157b7c8f29841efadc9a29ec154c327436904f11fad34332c5c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: cc48ee4723c80380f105d21f4a82541cbcb8ba9ffbe570e4411db6bce718207b4a89674424489578d03fb01f9485d56fa3e57fb618210a74e4893cd8d2dfef24
|
|
7
|
+
data.tar.gz: bf5501a504c4528ba4391124cc888c7a7ef4034190055aa6df94de8d6a1afa8dc5376720715ae79acd63bac4a33444d948f9cc497d8f06d3f06fb9c648709afa
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - 2026-07-16
|
|
4
|
+
|
|
5
|
+
- Add timezone-correct daily windows, atomic versioned state, and capped catch-up.
|
|
6
|
+
- Fetch complete merged-PR results with deterministic ordering and optional stats.
|
|
7
|
+
- Render safe, bounded Telegram HTML and deliver only to an allowlisted chat.
|
|
8
|
+
- Persist stable rendered chunks and resume at the next definitely-unsent chunk.
|
|
9
|
+
- Park permanent and ambiguous Telegram failures instead of replaying accepted messages.
|
|
10
|
+
- Add repeatable `--repo owner/name` input and a versioned JSON embedding contract.
|
|
11
|
+
- Add scheduled, explicit replay, dry-run, JSON, and stable exit-code contracts.
|
|
12
|
+
- Add non-root container, hardened systemd units, offline tests, and release smokes.
|
|
13
|
+
|
|
14
|
+
No tag, gem publication, or GitHub release is created by this preparation.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ivan Kuznetsov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# PRDigest
|
|
2
|
+
|
|
3
|
+
PRDigest is a deterministic Ruby oneshot that sends a daily digest of merged
|
|
4
|
+
GitHub pull requests to one allowlisted Telegram chat. It is designed for a
|
|
5
|
+
single operator on a small VPS: repositories are explicit, secrets stay in the
|
|
6
|
+
environment, and a hardened systemd timer owns scheduling.
|
|
7
|
+
|
|
8
|
+
## Requirements and installation
|
|
9
|
+
|
|
10
|
+
- Ruby 3.2–3.4 and `tzdata`
|
|
11
|
+
- a fine-grained GitHub token with read-only access to the listed repositories
|
|
12
|
+
- a dedicated Telegram bot and one destination chat ID
|
|
13
|
+
|
|
14
|
+
For development:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
bundle install
|
|
18
|
+
bundle exec prdigest version
|
|
19
|
+
GITHUB_TOKEN=... bundle exec prdigest run --config configs/config.example.yml --dry-run
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
To build and install the prepared gem without publishing it:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
gem build prdigest.gemspec
|
|
26
|
+
gem install prdigest-0.1.0.gem
|
|
27
|
+
prdigest version
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Configuration and secrets
|
|
31
|
+
|
|
32
|
+
Configuration lookup is strict: an explicit `--config PATH` wins, then
|
|
33
|
+
`PRDIGEST_CONFIG`, then an existing `/etc/prdigest/config.yml`. PRDigest refuses
|
|
34
|
+
to run when none exists. See `configs/config.example.yml` for every setting.
|
|
35
|
+
|
|
36
|
+
```yaml
|
|
37
|
+
timezone: Europe/London
|
|
38
|
+
schedule:
|
|
39
|
+
max_catchup_days: 7
|
|
40
|
+
github:
|
|
41
|
+
token_env: GITHUB_TOKEN
|
|
42
|
+
repos:
|
|
43
|
+
- owner/repo-one
|
|
44
|
+
- owner/repo-two
|
|
45
|
+
telegram:
|
|
46
|
+
token_env: TELEGRAM_BOT_TOKEN
|
|
47
|
+
chat_id_allowlist: [-1001234567890]
|
|
48
|
+
chat_id: -1001234567890
|
|
49
|
+
digest:
|
|
50
|
+
line_stats: true
|
|
51
|
+
send_empty: true
|
|
52
|
+
empty_message: "Merged PR digest — {date}\nTotal: 0 PRs"
|
|
53
|
+
state:
|
|
54
|
+
path: /var/lib/prdigest/state.json
|
|
55
|
+
delivery_path: /var/lib/prdigest/deliveries
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Repository order controls digest order. `max_catchup_days` must be `1..30`.
|
|
59
|
+
`chat_id` must appear in the non-empty allowlist; extra allowlisted IDs are
|
|
60
|
+
accepted for schema compatibility but v0.1.0 sends only to `chat_id`. Token
|
|
61
|
+
values belong in environment variables, never YAML.
|
|
62
|
+
|
|
63
|
+
## Commands and results
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
prdigest run [--config PATH] [--date YYYY-MM-DD] [--dry-run] [--json] [--repo OWNER/NAME ...]
|
|
67
|
+
prdigest serve
|
|
68
|
+
prdigest version
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
An ordinary `run` reads state, processes owed dates oldest-first, and writes
|
|
72
|
+
state after each settled day. `--date` replays exactly that local date and never
|
|
73
|
+
reads or writes state. `--dry-run` previews the explicit date or yesterday,
|
|
74
|
+
requires GitHub access, and constructs neither state nor Telegram delivery.
|
|
75
|
+
`serve` remains a compatibility stub; use the supplied timer.
|
|
76
|
+
Repeatable `--repo` values replace the configured repository list for that run.
|
|
77
|
+
They use the same strict `owner/name` validation and deterministic order as the
|
|
78
|
+
configuration, which lets callers such as Hive supply their registered projects
|
|
79
|
+
without owning a second digest implementation.
|
|
80
|
+
|
|
81
|
+
`--json` emits a versioned `prdigest-result` document with `status`, `mode`, `requested_days`,
|
|
82
|
+
`settled_days`, `skipped_days`, `failed_date`, `remaining_days`, nullable
|
|
83
|
+
`error`, dry-run `chunks`, and nullable structured `delivery` progress.
|
|
84
|
+
`delivery` reports `accepted_chunks`, `total_chunks`, `status`, and, on failure,
|
|
85
|
+
`failed_chunk`. Modes are `scheduled` and
|
|
86
|
+
`explicit_date_replay`; statuses are `success`, `dry_run`, `failure`, and
|
|
87
|
+
`partial_failure`.
|
|
88
|
+
|
|
89
|
+
| Exit | Meaning |
|
|
90
|
+
|---:|---|
|
|
91
|
+
| 0 | completed or dry-run |
|
|
92
|
+
| 1 | unexpected or render failure |
|
|
93
|
+
| 2 | CLI/configuration refusal |
|
|
94
|
+
| 3 | GitHub failure |
|
|
95
|
+
| 4 | Telegram failure |
|
|
96
|
+
| 5 | state failure |
|
|
97
|
+
| 6 | failure after earlier durable progress |
|
|
98
|
+
|
|
99
|
+
## Delivery and state semantics
|
|
100
|
+
|
|
101
|
+
Each local day is converted to independent UTC midnight boundaries, including
|
|
102
|
+
DST gaps and repeats. GitHub results are completely fetched and rendered before
|
|
103
|
+
the first Telegram message. A day is settled only after every chunk succeeds,
|
|
104
|
+
an enabled empty message succeeds, or an empty message is intentionally
|
|
105
|
+
suppressed.
|
|
106
|
+
|
|
107
|
+
Before sending, PRDigest stores the complete rendered chunk list in
|
|
108
|
+
`state.delivery_path`. It marks one chunk in flight before each request and
|
|
109
|
+
advances `next_chunk` only after Telegram definitely accepts it. A definite
|
|
110
|
+
429/5xx rejection receives at most three attempts; a later invocation resumes
|
|
111
|
+
the original rendered payload at the next unsent chunk. If the date cursor write
|
|
112
|
+
fails after complete delivery, the retry observes the completed delivery
|
|
113
|
+
checkpoint and sends nothing before repairing the cursor.
|
|
114
|
+
|
|
115
|
+
Telegram 400/invalid-response failures are permanent. Transport failures are
|
|
116
|
+
ambiguous because Telegram might have accepted the request before the
|
|
117
|
+
connection failed. Both cases remain parked and never replay automatically.
|
|
118
|
+
Inspect the checkpoint and Telegram chat, reconcile the uncertain chunk, then
|
|
119
|
+
move the checkpoint aside only when an intentional operator replay is safe.
|
|
120
|
+
Changing the chat or repository scope for an existing date also fails closed.
|
|
121
|
+
Explicit `--date` uses this same safety ledger, so a completed date is a no-op
|
|
122
|
+
unless its checkpoint is deliberately archived first.
|
|
123
|
+
|
|
124
|
+
State is secret-free JSON version 1:
|
|
125
|
+
|
|
126
|
+
```json
|
|
127
|
+
{"version":1,"timezone":"Europe/London","last_digested_date":"2026-07-15"}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
It may include a `last_skip` audit with `start_date`, `end_date`, and
|
|
131
|
+
`notice_pending`. Writes use an atomic mode-`0600` replacement and directory
|
|
132
|
+
fsync. Delivery directories are mode `0700`; delivery files and locks are mode
|
|
133
|
+
`0600`, and a per-date lock prevents concurrent sends for the same checkpoint.
|
|
134
|
+
Missing state means first run and requests yesterday only. Malformed,
|
|
135
|
+
future, unsupported, unreadable, or timezone-mismatched state fails closed with
|
|
136
|
+
exit 5.
|
|
137
|
+
|
|
138
|
+
When backlog exceeds the cap, PRDigest durably skips the oldest prefix and
|
|
139
|
+
processes only the newest window. That loss is intentional and reported. For a
|
|
140
|
+
timezone migration, stop the timer, preserve the old state for audit, move it
|
|
141
|
+
aside, change the configured timezone, use explicit replay for any required
|
|
142
|
+
dates, then restart the timer. Never silently edit a corrupt checkpoint; inspect
|
|
143
|
+
and repair ownership/JSON or restore a known-good copy first.
|
|
144
|
+
|
|
145
|
+
GitHub does not publish a search-index freshness guarantee. Keep the host
|
|
146
|
+
timezone equal to the configured digest timezone; the supplied 09:05 timer then
|
|
147
|
+
leaves about nine hours after local midnight. If a later audit finds a delayed
|
|
148
|
+
merge, use `--date YYYY-MM-DD` to replay it.
|
|
149
|
+
|
|
150
|
+
## systemd deployment
|
|
151
|
+
|
|
152
|
+
On Ubuntu, install Ruby, `tzdata`, the gem, and then:
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
sudo useradd --system --home /nonexistent --shell /usr/sbin/nologin prdigest
|
|
156
|
+
sudo install -d -o root -g prdigest -m 0750 /etc/prdigest
|
|
157
|
+
sudo install -o root -g prdigest -m 0640 configs/config.example.yml /etc/prdigest/config.yml
|
|
158
|
+
sudo install -o root -g root -m 0600 .env.example /etc/prdigest/.env
|
|
159
|
+
sudo install -o root -g root -m 0644 scripts/systemd/prdigest.service /etc/systemd/system/
|
|
160
|
+
sudo install -o root -g root -m 0644 scripts/systemd/prdigest.timer /etc/systemd/system/
|
|
161
|
+
sudo systemctl daemon-reload
|
|
162
|
+
sudo systemctl start prdigest.service
|
|
163
|
+
sudo systemctl enable --now prdigest.timer
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Edit the installed config and environment file before starting. systemd creates
|
|
167
|
+
`/var/lib/prdigest` as `prdigest:prdigest` mode `0700`; state files are `0600`.
|
|
168
|
+
Inspect with `systemctl status prdigest.service`, `systemctl list-timers
|
|
169
|
+
prdigest.timer`, and `journalctl -u prdigest.service`. Test a missed-day catch-up
|
|
170
|
+
by stopping the timer for a day, then starting the service. Journal output can
|
|
171
|
+
contain repository/date context, so restrict journal group membership and
|
|
172
|
+
retention.
|
|
173
|
+
|
|
174
|
+
Rollback by stopping the timer, installing the prior gem/image, restoring the
|
|
175
|
+
matching config and a known-good state backup, and restarting. Replay omitted
|
|
176
|
+
dates explicitly; do not move a checkpoint forward by hand.
|
|
177
|
+
|
|
178
|
+
## Container
|
|
179
|
+
|
|
180
|
+
The Alpine image includes `tzdata` and runs as the unprivileged `prdigest` user.
|
|
181
|
+
Initialize mounted ownership with the image before normal use:
|
|
182
|
+
|
|
183
|
+
```sh
|
|
184
|
+
docker build -t prdigest:0.1.0 .
|
|
185
|
+
docker volume create prdigest-state
|
|
186
|
+
docker run --rm --user root --entrypoint sh -v prdigest-state:/var/lib/prdigest prdigest:0.1.0 \
|
|
187
|
+
-c 'chown -R prdigest:prdigest /var/lib/prdigest && chmod 0700 /var/lib/prdigest'
|
|
188
|
+
docker run --rm --env-file /etc/prdigest/.env \
|
|
189
|
+
-v /etc/prdigest/config.yml:/etc/prdigest/config.yml:ro \
|
|
190
|
+
-v prdigest-state:/var/lib/prdigest prdigest:0.1.0
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Verification and troubleshooting
|
|
194
|
+
|
|
195
|
+
`bundle exec rake test` is fully offline. Release preparation also supplies
|
|
196
|
+
`test/smoke/gem_install.sh`, `test/smoke/docker.sh`, and
|
|
197
|
+
`test/smoke/systemd.sh`. Live GitHub boundary and Telegram allowlist smokes are
|
|
198
|
+
manual gates: retain timestamps and pass/fail only, never credentials, response
|
|
199
|
+
bodies, message text, or private titles.
|
|
200
|
+
|
|
201
|
+
- Exit 2: check config discovery, YAML, timezone, cap, chat allowlist, and env.
|
|
202
|
+
- Exit 3: check repository access, rate/search limits, and replay later.
|
|
203
|
+
- Exit 4: inspect `error.kind` and `delivery`. Retry ordinary `telegram` failures;
|
|
204
|
+
reconcile `telegram_ambiguous`, `telegram_permanent`, and
|
|
205
|
+
`delivery_checkpoint_permanent` before moving any checkpoint.
|
|
206
|
+
- Exit 5: check `/var/lib/prdigest`, mode/owner, JSON version, date, and timezone.
|
|
207
|
+
- Exit 6: earlier dates or a skipped prefix are durable; inspect the result before retry.
|
|
208
|
+
|
|
209
|
+
Concurrent scheduled runs remain unsupported because date-cursor scheduling is
|
|
210
|
+
single-owner. Delivery itself takes a nonblocking per-date lock, so a competing
|
|
211
|
+
sender fails instead of duplicating the same payload. The systemd oneshot is the
|
|
212
|
+
normal run coordination mechanism.
|
|
213
|
+
|
|
214
|
+
See [SECURITY.md](SECURITY.md) for token scope, rotation, and private-data flow.
|
|
215
|
+
|
|
216
|
+
## Non-goals
|
|
217
|
+
|
|
218
|
+
v0.1.0 has no built-in Hive configuration discovery, LLM content, web UI,
|
|
219
|
+
interactive bot commands,
|
|
220
|
+
multi-chat routing, organization discovery, non-GitHub forge support, or
|
|
221
|
+
long-running scheduler. This repository prepares v0.1.0 without tagging,
|
|
222
|
+
publishing the gem, or creating a release.
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
MIT
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
## Credentials
|
|
4
|
+
|
|
5
|
+
PRDigest reads tokens only from environment variables. Keep `/etc/prdigest/.env`
|
|
6
|
+
owned by `root:root` with mode `0600`; never put tokens in YAML, command-line
|
|
7
|
+
arguments, fixtures, logs, or issue reports.
|
|
8
|
+
|
|
9
|
+
Use a fine-grained GitHub personal access token limited to the repositories in
|
|
10
|
+
`github.repos`, with read-only metadata and pull-request access. Use a dedicated
|
|
11
|
+
Telegram bot whose `chat_id` is the only delivery target in the allowlist. Rotate
|
|
12
|
+
either token immediately if it may have appeared in output, then inspect and
|
|
13
|
+
restrict journal retention.
|
|
14
|
+
|
|
15
|
+
Private pull-request titles and author names cross from GitHub into the configured
|
|
16
|
+
Telegram chat. Treat that chat and its members as having access to repository
|
|
17
|
+
metadata. PRDigest refuses non-allowlisted chat IDs before opening a connection.
|
|
18
|
+
|
|
19
|
+
## Reporting
|
|
20
|
+
|
|
21
|
+
Report vulnerabilities privately to the maintainer address in the gem metadata.
|
|
22
|
+
Do not include credentials, token-bearing URLs, private PR content, config files,
|
|
23
|
+
state files, or unredacted journal output.
|
|
24
|
+
|
|
25
|
+
## Supported release
|
|
26
|
+
|
|
27
|
+
Security fixes target the latest published release. v0.1.0 is prepared here but
|
|
28
|
+
is not published, tagged, or released by the build process.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# prdigest — multi-repo merged-PR daily digest (Hive-independent)
|
|
2
|
+
|
|
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
|
+
|
|
11
|
+
github:
|
|
12
|
+
token_env: GITHUB_TOKEN
|
|
13
|
+
# explicit owner/name only — empty list is a hard error
|
|
14
|
+
repos:
|
|
15
|
+
- owner/repo-one
|
|
16
|
+
- owner/repo-two
|
|
17
|
+
|
|
18
|
+
telegram:
|
|
19
|
+
token_env: TELEGRAM_BOT_TOKEN
|
|
20
|
+
chat_id_allowlist:
|
|
21
|
+
- -1001234567890
|
|
22
|
+
chat_id: -1001234567890
|
|
23
|
+
|
|
24
|
+
digest:
|
|
25
|
+
line_stats: true
|
|
26
|
+
send_empty: true
|
|
27
|
+
empty_message: "Merged PR digest — {date}\nTotal: 0 PRs"
|
|
28
|
+
|
|
29
|
+
state:
|
|
30
|
+
path: /var/lib/prdigest/state.json
|
|
31
|
+
# Stable rendered chunks and next-unsent position, separate from the date cursor.
|
|
32
|
+
delivery_path: /var/lib/prdigest/deliveries
|
data/exe/prdigest
ADDED
data/lib/prdigest/cli.rb
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "json"
|
|
5
|
+
require "thor"
|
|
6
|
+
|
|
7
|
+
module Prdigest
|
|
8
|
+
class CLI < Thor
|
|
9
|
+
class ParseError < StandardError; end
|
|
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
|
|
14
|
+
|
|
15
|
+
desc "version", "Print version"
|
|
16
|
+
def version; end
|
|
17
|
+
|
|
18
|
+
desc "serve", "Compatibility stub; use the systemd timer"
|
|
19
|
+
def serve; end
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def invoke(argv = ARGV, out: $stdout, err: $stderr, env: ENV,
|
|
23
|
+
system_path: "/etc/prdigest/config.yml", runner_factory: nil)
|
|
24
|
+
json_intent = Array(argv).any? { |arg| arg == "--json" || arg.start_with?("--json=") }
|
|
25
|
+
parsed = parse(argv)
|
|
26
|
+
return print_version(out) if parsed[:command] == "version"
|
|
27
|
+
return print_help(out) if %w[help --help -h].include?(parsed[:command])
|
|
28
|
+
|
|
29
|
+
unless %w[run serve].include?(parsed[:command])
|
|
30
|
+
raise ParseError, "unknown command"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
path = Config.resolve_path(explicit: parsed[:config], env: env, system_path: system_path)
|
|
34
|
+
config = Config.load(path)
|
|
35
|
+
if parsed[:command] == "serve"
|
|
36
|
+
err.puts "prdigest serve: deferred in v0.1.0 — use systemd timer + `prdigest run`"
|
|
37
|
+
return 0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
validate_date!(parsed[:date]) if parsed[:date]
|
|
41
|
+
repositories = if parsed[:repos].empty?
|
|
42
|
+
nil
|
|
43
|
+
else
|
|
44
|
+
Config.normalize_repos(parsed[:repos], label: "--repo")
|
|
45
|
+
end
|
|
46
|
+
raise ConfigError, "GitHub token is missing" if config.github_token(env).empty?
|
|
47
|
+
if !parsed[:dry_run] && config.telegram_token(env).empty?
|
|
48
|
+
raise ConfigError, "Telegram bot token is missing"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
factory = runner_factory || ->(**options) { Runner.new(**options) }
|
|
52
|
+
result = factory.call(
|
|
53
|
+
config: config,
|
|
54
|
+
date: parsed[:date],
|
|
55
|
+
dry_run: parsed[:dry_run],
|
|
56
|
+
repositories: repositories,
|
|
57
|
+
env: env
|
|
58
|
+
).call
|
|
59
|
+
present(result, json: parsed[:json], out: out, err: err)
|
|
60
|
+
result.exit_code
|
|
61
|
+
rescue ParseError => e
|
|
62
|
+
result = Result.failure(mode: "scheduled", error_kind: "cli", message: e.message)
|
|
63
|
+
present(result, json: json_intent, out: out, err: err)
|
|
64
|
+
result.exit_code
|
|
65
|
+
rescue ConfigError => e
|
|
66
|
+
result = Result.failure(mode: parsed && parsed[:date] ? "explicit_date_replay" : "scheduled", error_kind: "config", message: e.message)
|
|
67
|
+
present(result, json: parsed ? parsed[:json] : json_intent, out: out, err: err)
|
|
68
|
+
result.exit_code
|
|
69
|
+
rescue StandardError => e
|
|
70
|
+
result = Result.failure(
|
|
71
|
+
mode: parsed && parsed[:date] ? "explicit_date_replay" : "scheduled",
|
|
72
|
+
error_kind: "internal",
|
|
73
|
+
message: "unexpected CLI failure (#{e.class})"
|
|
74
|
+
)
|
|
75
|
+
present(result, json: parsed ? parsed[:json] : json_intent, out: out, err: err)
|
|
76
|
+
result.exit_code
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
alias start invoke
|
|
80
|
+
|
|
81
|
+
def exit_on_failure?
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def parse(argv)
|
|
88
|
+
parsed = { command: nil, config: nil, date: nil, dry_run: false, json: false, repos: [] }
|
|
89
|
+
args = Array(argv).dup
|
|
90
|
+
until args.empty?
|
|
91
|
+
argument = args.shift
|
|
92
|
+
case argument
|
|
93
|
+
when "--help", "-h"
|
|
94
|
+
parsed[:command] = "help"
|
|
95
|
+
when "--dry-run"
|
|
96
|
+
parsed[:dry_run] = true
|
|
97
|
+
when "--json"
|
|
98
|
+
parsed[:json] = true
|
|
99
|
+
when "--config", "--date", "--repo"
|
|
100
|
+
value = args.shift
|
|
101
|
+
raise ParseError, "missing value for #{argument}" if value.nil? || value.start_with?("--")
|
|
102
|
+
if argument == "--repo"
|
|
103
|
+
parsed[:repos] << value
|
|
104
|
+
else
|
|
105
|
+
parsed[argument.delete_prefix("--").tr("-", "_").to_sym] = value
|
|
106
|
+
end
|
|
107
|
+
when /\A--config=(.*)\z/
|
|
108
|
+
raise ParseError, "missing value for --config" if Regexp.last_match(1).empty?
|
|
109
|
+
parsed[:config] = Regexp.last_match(1)
|
|
110
|
+
when /\A--date=(.*)\z/
|
|
111
|
+
raise ParseError, "missing value for --date" if Regexp.last_match(1).empty?
|
|
112
|
+
parsed[:date] = Regexp.last_match(1)
|
|
113
|
+
when /\A--repo=(.*)\z/
|
|
114
|
+
raise ParseError, "missing value for --repo" if Regexp.last_match(1).empty?
|
|
115
|
+
parsed[:repos] << Regexp.last_match(1)
|
|
116
|
+
when /\A-/
|
|
117
|
+
raise ParseError, "unknown option"
|
|
118
|
+
else
|
|
119
|
+
raise ParseError, "unexpected argument" if parsed[:command]
|
|
120
|
+
parsed[:command] = argument
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
parsed
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def validate_date!(value)
|
|
127
|
+
unless value.match?(/\A\d{4}-\d{2}-\d{2}\z/) && Date.iso8601(value).iso8601 == value
|
|
128
|
+
raise ConfigError, "date must use YYYY-MM-DD"
|
|
129
|
+
end
|
|
130
|
+
rescue Date::Error
|
|
131
|
+
raise ConfigError, "date must use YYYY-MM-DD"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def present(result, json:, out:, err:)
|
|
135
|
+
if json
|
|
136
|
+
out.puts JSON.generate(result.to_h)
|
|
137
|
+
elsif result.exit_code.zero?
|
|
138
|
+
if result.status == "dry_run"
|
|
139
|
+
out.puts result.chunks.join("\n\n")
|
|
140
|
+
else
|
|
141
|
+
out.puts "prdigest: success; settled=#{result.settled_days.length} skipped=#{result.skipped_days.length}"
|
|
142
|
+
end
|
|
143
|
+
else
|
|
144
|
+
if result.status == "partial_failure"
|
|
145
|
+
err.puts "prdigest: progress; settled=#{human_dates(result.settled_days)} " \
|
|
146
|
+
"skipped=#{human_dates(result.skipped_days)} remaining=#{human_dates(result.remaining_days)}"
|
|
147
|
+
end
|
|
148
|
+
err.puts "prdigest: #{result.error[:kind]}: #{result.error[:message]}"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def human_dates(dates)
|
|
153
|
+
values = Array(dates)
|
|
154
|
+
values.empty? ? "none" : values.join(",")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def print_version(out)
|
|
158
|
+
out.puts "prdigest #{VERSION}"
|
|
159
|
+
0
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def print_help(out)
|
|
163
|
+
out.puts "Usage: prdigest run [--config PATH] [--date YYYY-MM-DD] [--repo owner/name] [--dry-run] [--json]"
|
|
164
|
+
out.puts " prdigest serve | version"
|
|
165
|
+
0
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "time"
|
|
5
|
+
require "tzinfo"
|
|
6
|
+
|
|
7
|
+
module Prdigest
|
|
8
|
+
class Clock
|
|
9
|
+
Window = Data.define(:local_date, :start_time, :end_time) do
|
|
10
|
+
def cover?(time)
|
|
11
|
+
time >= start_time && time < end_time
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def zero_length?
|
|
15
|
+
start_time == end_time
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(timezone:, now: -> { Time.now })
|
|
20
|
+
@timezone = TZInfo::Timezone.get(timezone)
|
|
21
|
+
@now = now
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def yesterday
|
|
25
|
+
(@timezone.to_local(@now.call.utc).to_date - 1)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def window(local_date)
|
|
29
|
+
date = Date.iso8601(local_date.to_s)
|
|
30
|
+
Window.new(date, resolve_midnight(date), resolve_midnight(date + 1))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def resolve_midnight(date)
|
|
36
|
+
local = Time.utc(date.year, date.month, date.day)
|
|
37
|
+
periods = @timezone.periods_for_local(local)
|
|
38
|
+
return periods.map { |period| (local - period.utc_total_offset).utc }.min unless periods.empty?
|
|
39
|
+
|
|
40
|
+
transition = @timezone.transitions_up_to(local + 172_800, local - 172_800).find do |candidate|
|
|
41
|
+
before = Time.at(candidate.timestamp_value + candidate.previous_offset.utc_total_offset).utc
|
|
42
|
+
after = Time.at(candidate.timestamp_value + candidate.offset.utc_total_offset).utc
|
|
43
|
+
after > before && local >= before && local < after
|
|
44
|
+
end
|
|
45
|
+
raise ConfigError, "cannot resolve local midnight for #{date}" unless transition
|
|
46
|
+
|
|
47
|
+
Time.at(transition.timestamp_value).utc
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|