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 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
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "prdigest"
5
+
6
+ exit Prdigest::CLI.invoke(ARGV)
@@ -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