prdigest 0.2.0 → 0.4.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.
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.4.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:
@@ -149,17 +149,13 @@ files:
149
149
  - lib/prdigest/config.rb
150
150
  - lib/prdigest/delivery_checkpoint_store.rb
151
151
  - lib/prdigest/digest.rb
152
+ - lib/prdigest/document.rb
152
153
  - lib/prdigest/facts.rb
153
154
  - lib/prdigest/facts_runner.rb
154
155
  - lib/prdigest/github.rb
155
156
  - lib/prdigest/openai_compatible.rb
156
157
  - lib/prdigest/prose_renderer.rb
157
158
  - 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
159
  - lib/prdigest/telegram.rb
164
160
  - lib/prdigest/version.rb
165
161
  - scripts/systemd/prdigest.service
@@ -188,5 +184,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
188
184
  requirements: []
189
185
  rubygems_version: 3.6.9
190
186
  specification_version: 4
191
- summary: Deterministic multi-repo merged-PR facts and digests
187
+ summary: Merged-PR facts for agents and provider-written prose
192
188
  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