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.
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "timeout"
7
+ require "uri"
8
+
9
+ module Prdigest
10
+ class Telegram
11
+ MAX_ATTEMPTS = 3
12
+ MAX_RETRY_WAIT = 60
13
+ API_ORIGIN = "https://api.telegram.org"
14
+
15
+ class NetHTTPTransport
16
+ def call(uri:, request:, open_timeout:, read_timeout:, write_timeout:)
17
+ http = Net::HTTP.new(uri.host, uri.port)
18
+ http.use_ssl = true
19
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
20
+ http.verify_hostname = true if http.respond_to?(:verify_hostname=)
21
+ http.open_timeout = open_timeout
22
+ http.read_timeout = read_timeout
23
+ http.write_timeout = write_timeout if http.respond_to?(:write_timeout=)
24
+ http.request(request)
25
+ end
26
+ end
27
+
28
+ def initialize(token:, chat_id:, allowlist:, transport: NetHTTPTransport.new, sleeper: ->(seconds) { sleep(seconds) })
29
+ @token = token.to_s
30
+ @chat_id = Integer(chat_id)
31
+ @allowlist = Array(allowlist).map { |id| Integer(id) }.freeze
32
+ @transport = transport
33
+ @sleeper = sleeper
34
+ @delivered_chunk = false
35
+ end
36
+
37
+ def deliver(chunks, digest_date:, checkpoint_store:, scope:)
38
+ refuse_unlisted_chat! unless @allowlist.include?(@chat_id)
39
+ checkpoint_store.with_checkpoint(
40
+ date: digest_date,
41
+ chat_id: @chat_id,
42
+ scope: scope,
43
+ chunks: chunks
44
+ ) do |delivery|
45
+ @delivered_chunk ||= delivery.next_chunk.positive?
46
+ (delivery.next_chunk...delivery.chunks.length).each do |index|
47
+ @sleeper.call(1) if @delivered_chunk
48
+ deliver_chunk(delivery, index)
49
+ @delivered_chunk = true
50
+ end
51
+ delivery.delivery
52
+ end
53
+ end
54
+
55
+ def inspect
56
+ "#<#{self.class} chat_id=#{@chat_id} token=[REDACTED]>"
57
+ end
58
+
59
+ alias to_s inspect
60
+
61
+ private
62
+
63
+ def deliver_chunk(delivery, index)
64
+ attempts = 0
65
+ waited = 0
66
+ loop do
67
+ attempts += 1
68
+ delivery.begin_attempt(index)
69
+ begin
70
+ response = @transport.call(
71
+ uri: request_uri,
72
+ request: build_request(delivery.chunks.fetch(index)),
73
+ open_timeout: 10,
74
+ read_timeout: 30,
75
+ write_timeout: 30
76
+ )
77
+ rescue StandardError => e
78
+ fail_ambiguous!(delivery, index, e)
79
+ end
80
+
81
+ disposition, delay, kind = classify_response(response)
82
+ if disposition == :success
83
+ delivery.accept(index)
84
+ return true
85
+ end
86
+
87
+ permanent = disposition == :failure
88
+ delivery.reject(
89
+ index,
90
+ kind: permanent ? "telegram_permanent" : "telegram",
91
+ message: failure_message(kind),
92
+ permanent: permanent
93
+ )
94
+ fail_send!(
95
+ delivery,
96
+ index,
97
+ kind: permanent ? "telegram_permanent" : "telegram",
98
+ reason: kind
99
+ ) if permanent || attempts >= MAX_ATTEMPTS
100
+
101
+ waited = wait!(delay || attempts, kind, waited, delivery, index)
102
+ end
103
+ end
104
+
105
+ def build_request(text)
106
+ request = Net::HTTP::Post.new(request_uri.request_uri)
107
+ request["Content-Type"] = "application/json"
108
+ request.body = JSON.generate(
109
+ chat_id: @chat_id,
110
+ text: text,
111
+ parse_mode: "HTML",
112
+ link_preview_options: { is_disabled: true }
113
+ )
114
+ request
115
+ end
116
+
117
+ def request_uri
118
+ URI("#{API_ORIGIN}/bot#{@token}/sendMessage")
119
+ end
120
+
121
+ def classify_response(response)
122
+ code = Integer(response.code)
123
+ payload = JSON.parse(response.body.to_s)
124
+ return [:success, nil, nil] if (200..299).cover?(code) && payload["ok"] == true
125
+
126
+ if code == 429
127
+ delay = Integer(payload.dig("parameters", "retry_after"), exception: false)
128
+ return [:retry, delay, "rate_limited"]
129
+ end
130
+ return [:retry, nil, "server"] if (500..599).cover?(code)
131
+
132
+ [:failure, nil, "http_#{code}"]
133
+ rescue JSON::ParserError, TypeError, ArgumentError
134
+ code && (500..599).cover?(code) ? [:retry, nil, "server"] : [:failure, nil, "invalid_response"]
135
+ end
136
+
137
+ def wait!(seconds, kind, waited, delivery, index)
138
+ delay = Integer(seconds, exception: false)
139
+ total = waited + delay.to_i
140
+ fail_send!(delivery, index, reason: "retry_wait_exceeded") unless delay && total <= MAX_RETRY_WAIT
141
+ @sleeper.call(delay)
142
+ total
143
+ rescue SendError
144
+ raise
145
+ rescue StandardError
146
+ fail_send!(delivery, index, reason: kind)
147
+ end
148
+
149
+ def refuse_unlisted_chat!
150
+ raise SendError.new("Telegram delivery refused: chat is not allowlisted", kind: "telegram_refused")
151
+ end
152
+
153
+ def fail_ambiguous!(delivery, index, error)
154
+ reason = case error
155
+ when Timeout::Error then "timeout"
156
+ when IOError then "io"
157
+ when SocketError then "socket"
158
+ when SystemCallError then "system"
159
+ else "transport"
160
+ end
161
+ raise SendError.new(
162
+ "Telegram send outcome is ambiguous (#{reason})",
163
+ kind: "telegram_ambiguous",
164
+ delivery: delivery.delivery(failed_chunk: index)
165
+ )
166
+ end
167
+
168
+ def fail_send!(delivery, index, kind: "telegram", reason:)
169
+ raise SendError.new(
170
+ failure_message(reason),
171
+ kind: kind,
172
+ delivery: delivery.delivery(failed_chunk: index)
173
+ )
174
+ end
175
+
176
+ def failure_message(reason)
177
+ "Telegram send failed (#{reason})"
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prdigest
4
+ VERSION = "0.1.0"
5
+ end
data/lib/prdigest.rb ADDED
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prdigest
4
+ class Error < StandardError; end
5
+ class ConfigError < Error; end
6
+ class StateError < Error; end
7
+ class FetchError < Error
8
+ attr_reader :kind
9
+
10
+ def initialize(message, kind: "github")
11
+ @kind = kind
12
+ super(message)
13
+ end
14
+ end
15
+ class RenderError < Error
16
+ attr_reader :kind
17
+
18
+ def initialize(message, kind: "render")
19
+ @kind = kind
20
+ super(message)
21
+ end
22
+ end
23
+ class SendError < Error
24
+ attr_reader :kind, :delivery
25
+
26
+ def initialize(message, kind: "telegram", delivery: {})
27
+ @kind = kind
28
+ @delivery = delivery.transform_keys(&:to_sym).freeze
29
+ super(message)
30
+ end
31
+ end
32
+ end
33
+
34
+ require_relative "prdigest/version"
35
+ require_relative "prdigest/clock"
36
+ require_relative "prdigest/config"
37
+ require_relative "prdigest/digest"
38
+ require_relative "prdigest/github"
39
+ require_relative "prdigest/renderer"
40
+ require_relative "prdigest/delivery_checkpoint_store"
41
+ require_relative "prdigest/telegram"
42
+ require_relative "prdigest/result"
43
+ require_relative "prdigest/schedule"
44
+ require_relative "prdigest/state"
45
+ require_relative "prdigest/runner"
46
+ require_relative "prdigest/cli"
@@ -0,0 +1,27 @@
1
+ [Unit]
2
+ Description=prdigest one-shot merged-PR digests (use with timer)
3
+ After=network-online.target
4
+ Wants=network-online.target
5
+
6
+ [Service]
7
+ Type=oneshot
8
+ User=prdigest
9
+ Group=prdigest
10
+ EnvironmentFile=/etc/prdigest/.env
11
+ ExecStart=/usr/local/bin/prdigest run --config /etc/prdigest/config.yml
12
+ StateDirectory=prdigest
13
+ StateDirectoryMode=0700
14
+ UMask=0077
15
+ TimeoutStartSec=1h
16
+ Nice=10
17
+ NoNewPrivileges=true
18
+ ProtectSystem=strict
19
+ ProtectHome=true
20
+ ReadWritePaths=/var/lib/prdigest
21
+ PrivateTmp=true
22
+ PrivateDevices=true
23
+ ProtectKernelTunables=true
24
+ ProtectKernelModules=true
25
+ ProtectControlGroups=true
26
+ RestrictSUIDSGID=true
27
+ LockPersonality=true
@@ -0,0 +1,12 @@
1
+ [Unit]
2
+ Description=Daily prdigest merged-PR digest
3
+
4
+ [Timer]
5
+ # Uses the host timezone. Match it to config.yml so 09:05 leaves a nine-hour
6
+ # indexing buffer after the previous local day.
7
+ OnCalendar=*-*-* 09:05:00
8
+ Persistent=true
9
+ Unit=prdigest.service
10
+
11
+ [Install]
12
+ WantedBy=timers.target
metadata ADDED
@@ -0,0 +1,186 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: prdigest
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ivan Kuznetsov
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: thor
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: octokit
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '9.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '9.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: faraday-retry
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.2'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.2'
54
+ - !ruby/object:Gem::Dependency
55
+ name: tzinfo
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2.0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: erb
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '4.0'
75
+ - - "<"
76
+ - !ruby/object:Gem::Version
77
+ version: '7.0'
78
+ type: :runtime
79
+ prerelease: false
80
+ version_requirements: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '4.0'
85
+ - - "<"
86
+ - !ruby/object:Gem::Version
87
+ version: '7.0'
88
+ - !ruby/object:Gem::Dependency
89
+ name: minitest
90
+ requirement: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '5.25'
95
+ type: :development
96
+ prerelease: false
97
+ version_requirements: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - "~>"
100
+ - !ruby/object:Gem::Version
101
+ version: '5.25'
102
+ - !ruby/object:Gem::Dependency
103
+ name: rake
104
+ requirement: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - "~>"
107
+ - !ruby/object:Gem::Version
108
+ version: '13.0'
109
+ type: :development
110
+ prerelease: false
111
+ version_requirements: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - "~>"
114
+ - !ruby/object:Gem::Version
115
+ version: '13.0'
116
+ - !ruby/object:Gem::Dependency
117
+ name: webmock
118
+ requirement: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - "~>"
121
+ - !ruby/object:Gem::Version
122
+ version: '3.25'
123
+ type: :development
124
+ prerelease: false
125
+ version_requirements: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - "~>"
128
+ - !ruby/object:Gem::Version
129
+ version: '3.25'
130
+ description: 'Config-driven GitHub merged-PR digests with Telegram delivery. Hive-independent:
131
+ repo list + GitHub token + chat allowlist.'
132
+ email:
133
+ - ivan@ikuznetsov.com
134
+ executables:
135
+ - prdigest
136
+ extensions: []
137
+ extra_rdoc_files: []
138
+ files:
139
+ - CHANGELOG.md
140
+ - LICENSE
141
+ - README.md
142
+ - SECURITY.md
143
+ - configs/config.example.yml
144
+ - exe/prdigest
145
+ - lib/prdigest.rb
146
+ - lib/prdigest/cli.rb
147
+ - lib/prdigest/clock.rb
148
+ - lib/prdigest/config.rb
149
+ - lib/prdigest/delivery_checkpoint_store.rb
150
+ - lib/prdigest/digest.rb
151
+ - lib/prdigest/github.rb
152
+ - lib/prdigest/renderer.rb
153
+ - lib/prdigest/result.rb
154
+ - lib/prdigest/runner.rb
155
+ - lib/prdigest/schedule.rb
156
+ - lib/prdigest/state.rb
157
+ - lib/prdigest/telegram.rb
158
+ - lib/prdigest/version.rb
159
+ - scripts/systemd/prdigest.service
160
+ - scripts/systemd/prdigest.timer
161
+ homepage: https://github.com/ivankuznetsov/prdigest
162
+ licenses:
163
+ - MIT
164
+ metadata:
165
+ homepage_uri: https://github.com/ivankuznetsov/prdigest
166
+ source_code_uri: https://github.com/ivankuznetsov/prdigest/tree/main
167
+ bug_tracker_uri: https://github.com/ivankuznetsov/prdigest/issues
168
+ rubygems_mfa_required: 'true'
169
+ rdoc_options: []
170
+ require_paths:
171
+ - lib
172
+ required_ruby_version: !ruby/object:Gem::Requirement
173
+ requirements:
174
+ - - ">="
175
+ - !ruby/object:Gem::Version
176
+ version: 3.2.0
177
+ required_rubygems_version: !ruby/object:Gem::Requirement
178
+ requirements:
179
+ - - ">="
180
+ - !ruby/object:Gem::Version
181
+ version: '0'
182
+ requirements: []
183
+ rubygems_version: 3.6.9
184
+ specification_version: 4
185
+ summary: Standalone multi-repo daily merged-PR digests for Telegram
186
+ test_files: []