prdigest 0.1.1 → 0.2.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 +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +374 -142
- data/SECURITY.md +36 -1
- data/configs/config.example.yml +9 -0
- data/lib/prdigest/cli.rb +169 -5
- data/lib/prdigest/collector.rb +38 -0
- data/lib/prdigest/config.rb +97 -5
- data/lib/prdigest/delivery_checkpoint_store.rb +17 -4
- data/lib/prdigest/facts.rb +76 -0
- data/lib/prdigest/facts_runner.rb +32 -0
- data/lib/prdigest/openai_compatible.rb +172 -0
- data/lib/prdigest/prose_renderer.rb +36 -0
- data/lib/prdigest/prose_runner.rb +102 -0
- data/lib/prdigest/result.rb +2 -1
- data/lib/prdigest/runner.rb +7 -12
- data/lib/prdigest/telegram.rb +3 -2
- data/lib/prdigest/version.rb +1 -1
- data/lib/prdigest.rb +14 -0
- metadata +10 -4
data/SECURITY.md
CHANGED
|
@@ -12,10 +12,43 @@ Telegram bot whose `chat_id` is the only delivery target in the allowlist. Rotat
|
|
|
12
12
|
either token immediately if it may have appeared in output, then inspect and
|
|
13
13
|
restrict journal retention.
|
|
14
14
|
|
|
15
|
+
Standalone prose uses the environment variable named by
|
|
16
|
+
`prose.api_key_env`. Never place the provider key itself in YAML, shell history,
|
|
17
|
+
arguments, checkpoints, fixtures, or logs. Remote provider URLs must use HTTPS;
|
|
18
|
+
plaintext HTTP is accepted only for exact `localhost`, IPv4 `127.0.0.0/8`, or
|
|
19
|
+
IPv6 `::1` loopback hosts. PRDigest rejects provider URLs with embedded
|
|
20
|
+
credentials, query strings, or fragments and does not include provider response
|
|
21
|
+
bodies in errors. Generated prose containing C0/C1 terminal controls is rejected
|
|
22
|
+
before stdout, checkpoints, or Telegram; tabs and newlines remain allowed.
|
|
23
|
+
|
|
15
24
|
Private pull-request titles and author names cross from GitHub into the configured
|
|
16
25
|
Telegram chat. Treat that chat and its members as having access to repository
|
|
17
26
|
metadata. PRDigest refuses non-allowlisted chat IDs before opening a connection.
|
|
18
27
|
|
|
28
|
+
`prdigest facts` writes repository names, titles, authors, URLs, merge times, and
|
|
29
|
+
optional statistics to stdout. The caller owns that output after the single JSON
|
|
30
|
+
document is emitted; avoid terminal capture, shell tracing, and logs that are
|
|
31
|
+
broader than the repository's audience.
|
|
32
|
+
|
|
33
|
+
OpenClaw mode sends that facts document into the configured OpenClaw execution
|
|
34
|
+
and model boundary so OpenClaw can write prose. Standalone `prdigest prose`
|
|
35
|
+
sends the same complete document plus the configured model name to the
|
|
36
|
+
OpenAI-compatible endpoint. Private repositories therefore require an OpenClaw
|
|
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.
|
|
40
|
+
|
|
41
|
+
Pull-request fields are untrusted input. The built-in provider prompt and the
|
|
42
|
+
OpenClaw skill explicitly classify the facts JSON as data, never instructions,
|
|
43
|
+
but operators should still restrict tools and authority available to any model
|
|
44
|
+
processing private repository content. The OpenClaw skill must not make a second
|
|
45
|
+
GitHub query, deliver messages, install software silently, or print credentials.
|
|
46
|
+
|
|
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.
|
|
51
|
+
|
|
19
52
|
## Reporting
|
|
20
53
|
|
|
21
54
|
Report vulnerabilities privately to the maintainer address in the gem metadata.
|
|
@@ -25,4 +58,6 @@ state files, or unredacted journal output.
|
|
|
25
58
|
## Supported release
|
|
26
59
|
|
|
27
60
|
Security fixes target the latest published release. The build and test process
|
|
28
|
-
does not tag or publish releases automatically.
|
|
61
|
+
does not tag or publish releases automatically. The exact-tag release workflow
|
|
62
|
+
uses a RubyGems trusted publisher and a short-lived GitHub OIDC credential; no
|
|
63
|
+
long-lived RubyGems API key is stored in the repository or Actions secrets.
|
data/configs/config.example.yml
CHANGED
|
@@ -26,6 +26,15 @@ digest:
|
|
|
26
26
|
send_empty: true
|
|
27
27
|
empty_message: "Merged PR digest — {date}\nTotal: 0 PRs"
|
|
28
28
|
|
|
29
|
+
prose:
|
|
30
|
+
# Used only by `prdigest prose`; `run` and `facts` never contact this endpoint.
|
|
31
|
+
provider: openai_compatible
|
|
32
|
+
base_url: https://openrouter.ai/api/v1
|
|
33
|
+
# Replace with a model identifier accepted by the configured endpoint.
|
|
34
|
+
model: provider/model
|
|
35
|
+
# The key value belongs in the environment, never this file.
|
|
36
|
+
api_key_env: OPENROUTER_API_KEY
|
|
37
|
+
|
|
29
38
|
state:
|
|
30
39
|
path: /var/lib/prdigest/state.json
|
|
31
40
|
# Stable rendered chunks and next-unsent position, separate from the date cursor.
|
data/lib/prdigest/cli.rb
CHANGED
|
@@ -12,6 +12,12 @@ module Prdigest
|
|
|
12
12
|
def run_cmd; end
|
|
13
13
|
map "run" => :run_cmd
|
|
14
14
|
|
|
15
|
+
desc "facts", "Print deterministic merged-PR facts as JSON"
|
|
16
|
+
def facts; end
|
|
17
|
+
|
|
18
|
+
desc "prose", "Generate an optional AI-written merged-PR digest"
|
|
19
|
+
def prose; end
|
|
20
|
+
|
|
15
21
|
desc "version", "Print version"
|
|
16
22
|
def version; end
|
|
17
23
|
|
|
@@ -20,20 +26,27 @@ module Prdigest
|
|
|
20
26
|
|
|
21
27
|
class << self
|
|
22
28
|
def invoke(argv = ARGV, out: $stdout, err: $stderr, env: ENV,
|
|
23
|
-
system_path: "/etc/prdigest/config.yml", runner_factory: nil
|
|
29
|
+
system_path: "/etc/prdigest/config.yml", runner_factory: nil,
|
|
30
|
+
facts_runner_factory: nil, prose_runner_factory: nil)
|
|
24
31
|
json_intent = Array(argv).any? { |arg| arg == "--json" || arg.start_with?("--json=") }
|
|
32
|
+
intent = command_intent(argv)
|
|
33
|
+
facts_intent = intent == "facts"
|
|
34
|
+
prose_intent = intent == "prose"
|
|
25
35
|
parsed = parse(argv)
|
|
36
|
+
validate_command_options!(parsed, intent: intent)
|
|
26
37
|
return print_version(out) if parsed[:command] == "version"
|
|
38
|
+
raise ParseError, "--help is not valid for facts" if facts_intent && parsed[:command] == "help"
|
|
27
39
|
return print_help(out) if %w[help --help -h].include?(parsed[:command])
|
|
28
40
|
|
|
29
|
-
unless %w[run serve].include?(parsed[:command])
|
|
41
|
+
unless %w[facts prose run serve].include?(parsed[:command])
|
|
30
42
|
raise ParseError, "unknown command"
|
|
31
43
|
end
|
|
32
44
|
|
|
33
45
|
path = Config.resolve_path(explicit: parsed[:config], env: env, system_path: system_path)
|
|
34
|
-
|
|
46
|
+
capability = config_capability(parsed)
|
|
47
|
+
config = Config.load(path, capability: capability)
|
|
35
48
|
if parsed[:command] == "serve"
|
|
36
|
-
err.puts "prdigest serve:
|
|
49
|
+
err.puts "prdigest serve: not implemented — use systemd timer + `prdigest run`"
|
|
37
50
|
return 0
|
|
38
51
|
end
|
|
39
52
|
|
|
@@ -43,6 +56,42 @@ module Prdigest
|
|
|
43
56
|
else
|
|
44
57
|
Config.normalize_repos(parsed[:repos], label: "--repo")
|
|
45
58
|
end
|
|
59
|
+
if parsed[:command] == "facts"
|
|
60
|
+
raise ConfigError, "GitHub token is missing" if config.github_token(env).empty?
|
|
61
|
+
|
|
62
|
+
factory = facts_runner_factory || ->(**options) { FactsRunner.new(**options) }
|
|
63
|
+
document = factory.call(
|
|
64
|
+
config: config,
|
|
65
|
+
date: parsed[:date],
|
|
66
|
+
repositories: repositories,
|
|
67
|
+
env: env
|
|
68
|
+
).call
|
|
69
|
+
out.puts JSON.generate(document)
|
|
70
|
+
return 0
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if parsed[:command] == "prose"
|
|
74
|
+
if parsed[:deliver]
|
|
75
|
+
raise ConfigError, "Telegram bot token is missing" if config.telegram_token(env).empty?
|
|
76
|
+
else
|
|
77
|
+
raise ConfigError, "GitHub token is missing" if config.github_token(env).empty?
|
|
78
|
+
if config.prose_api_key(env).empty?
|
|
79
|
+
raise ConfigError, "prose API key environment variable is unset"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
factory = prose_runner_factory || ->(**options) { ProseRunner.new(**options) }
|
|
84
|
+
outcome = factory.call(
|
|
85
|
+
config: config,
|
|
86
|
+
date: parsed[:date],
|
|
87
|
+
deliver: parsed[:deliver],
|
|
88
|
+
repositories: repositories,
|
|
89
|
+
env: env
|
|
90
|
+
).call
|
|
91
|
+
present_prose(outcome, out: out)
|
|
92
|
+
return 0
|
|
93
|
+
end
|
|
94
|
+
|
|
46
95
|
raise ConfigError, "GitHub token is missing" if config.github_token(env).empty?
|
|
47
96
|
if !parsed[:dry_run] && config.telegram_token(env).empty?
|
|
48
97
|
raise ConfigError, "Telegram bot token is missing"
|
|
@@ -59,14 +108,31 @@ module Prdigest
|
|
|
59
108
|
present(result, json: parsed[:json], out: out, err: err)
|
|
60
109
|
result.exit_code
|
|
61
110
|
rescue ParseError => e
|
|
111
|
+
return present_facts_failure(out, "cli", e.message) if facts_intent
|
|
112
|
+
return present_prose_failure(err, "cli", e.message) if prose_intent
|
|
113
|
+
|
|
62
114
|
result = Result.failure(mode: "scheduled", error_kind: "cli", message: e.message)
|
|
63
115
|
present(result, json: json_intent, out: out, err: err)
|
|
64
116
|
result.exit_code
|
|
65
117
|
rescue ConfigError => e
|
|
118
|
+
return present_facts_failure(out, "config", e.message) if facts_intent
|
|
119
|
+
return present_prose_failure(err, "config", safe_prose_message(e, config: config, env: env)) if prose_intent
|
|
120
|
+
|
|
66
121
|
result = Result.failure(mode: parsed && parsed[:date] ? "explicit_date_replay" : "scheduled", error_kind: "config", message: e.message)
|
|
67
122
|
present(result, json: parsed ? parsed[:json] : json_intent, out: out, err: err)
|
|
68
123
|
result.exit_code
|
|
69
124
|
rescue StandardError => e
|
|
125
|
+
if facts_intent
|
|
126
|
+
kind = e.is_a?(FetchError) ? e.kind : "internal"
|
|
127
|
+
message = e.is_a?(FetchError) ? e.message : "unexpected facts failure (#{e.class})"
|
|
128
|
+
return present_facts_failure(out, kind, message)
|
|
129
|
+
end
|
|
130
|
+
if prose_intent
|
|
131
|
+
kind = prose_error_kind(e)
|
|
132
|
+
message = safe_prose_message(e, config: config, env: env)
|
|
133
|
+
return present_prose_failure(err, kind, message)
|
|
134
|
+
end
|
|
135
|
+
|
|
70
136
|
result = Result.failure(
|
|
71
137
|
mode: parsed && parsed[:date] ? "explicit_date_replay" : "scheduled",
|
|
72
138
|
error_kind: "internal",
|
|
@@ -84,8 +150,32 @@ module Prdigest
|
|
|
84
150
|
|
|
85
151
|
private
|
|
86
152
|
|
|
153
|
+
def command_intent(argv)
|
|
154
|
+
args = Array(argv).dup
|
|
155
|
+
until args.empty?
|
|
156
|
+
argument = args.shift
|
|
157
|
+
case argument
|
|
158
|
+
when "--config", "--date", "--repo"
|
|
159
|
+
args.shift
|
|
160
|
+
when /\A--/
|
|
161
|
+
next
|
|
162
|
+
else
|
|
163
|
+
return argument
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
|
|
87
169
|
def parse(argv)
|
|
88
|
-
parsed = {
|
|
170
|
+
parsed = {
|
|
171
|
+
command: nil,
|
|
172
|
+
config: nil,
|
|
173
|
+
date: nil,
|
|
174
|
+
dry_run: false,
|
|
175
|
+
json: false,
|
|
176
|
+
deliver: false,
|
|
177
|
+
repos: []
|
|
178
|
+
}
|
|
89
179
|
args = Array(argv).dup
|
|
90
180
|
until args.empty?
|
|
91
181
|
argument = args.shift
|
|
@@ -96,6 +186,8 @@ module Prdigest
|
|
|
96
186
|
parsed[:dry_run] = true
|
|
97
187
|
when "--json"
|
|
98
188
|
parsed[:json] = true
|
|
189
|
+
when "--deliver"
|
|
190
|
+
parsed[:deliver] = true
|
|
99
191
|
when "--config", "--date", "--repo"
|
|
100
192
|
value = args.shift
|
|
101
193
|
raise ParseError, "missing value for #{argument}" if value.nil? || value.start_with?("--")
|
|
@@ -131,6 +223,76 @@ module Prdigest
|
|
|
131
223
|
raise ConfigError, "date must use YYYY-MM-DD"
|
|
132
224
|
end
|
|
133
225
|
|
|
226
|
+
def validate_command_options!(parsed, intent:)
|
|
227
|
+
command = intent || parsed.fetch(:command)
|
|
228
|
+
if parsed[:deliver] && command != "prose"
|
|
229
|
+
raise ParseError, "--deliver is only valid for prose"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
if command == "facts"
|
|
233
|
+
raise ParseError, "--dry-run is not valid for facts" if parsed[:dry_run]
|
|
234
|
+
raise ParseError, "--json is not valid for facts; output is always JSON" if parsed[:json]
|
|
235
|
+
elsif command == "prose"
|
|
236
|
+
raise ParseError, "--dry-run is not valid for prose" if parsed[:dry_run]
|
|
237
|
+
raise ParseError, "--json is not valid for prose" if parsed[:json]
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def config_capability(parsed)
|
|
242
|
+
case parsed.fetch(:command)
|
|
243
|
+
when "facts" then :facts
|
|
244
|
+
when "prose" then parsed[:deliver] ? :prose_delivery : :prose
|
|
245
|
+
else :run
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def present_facts_failure(out, error_kind, message)
|
|
250
|
+
out.puts JSON.generate(Facts.failure(error_kind: error_kind, message: message))
|
|
251
|
+
Result::EXIT_CODES.fetch(error_kind.to_s, 1)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def present_prose(outcome, out:)
|
|
255
|
+
if outcome.delivered?
|
|
256
|
+
delivery = outcome.delivery
|
|
257
|
+
out.puts(
|
|
258
|
+
"prdigest: prose delivered; date=#{outcome.date} " \
|
|
259
|
+
"chunks=#{delivery.fetch(:total_chunks)}"
|
|
260
|
+
)
|
|
261
|
+
else
|
|
262
|
+
out.write("#{outcome.prose.to_s.sub(/(?:\r?\n)*\z/, "")}\n")
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def present_prose_failure(err, error_kind, message)
|
|
267
|
+
err.puts "prdigest: #{error_kind}: #{message}"
|
|
268
|
+
Result::EXIT_CODES.fetch(error_kind.to_s, 1)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def prose_error_kind(error)
|
|
272
|
+
case error
|
|
273
|
+
when FetchError then error.kind
|
|
274
|
+
when SendError then error.kind
|
|
275
|
+
when StateError then "state"
|
|
276
|
+
when GenerationError then error.kind
|
|
277
|
+
when RenderError then error.kind
|
|
278
|
+
else "internal"
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def safe_prose_message(error, config:, env:)
|
|
283
|
+
message = error.is_a?(Error) ? error.message : "unexpected prose failure (#{error.class})"
|
|
284
|
+
return message unless config
|
|
285
|
+
|
|
286
|
+
secrets = [
|
|
287
|
+
config.github_token(env),
|
|
288
|
+
config.telegram_token(env),
|
|
289
|
+
config.prose_api_key(env)
|
|
290
|
+
]
|
|
291
|
+
secrets.reject(&:empty?).reduce(message.dup) do |safe, secret|
|
|
292
|
+
safe.gsub(secret, "[REDACTED]")
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
134
296
|
def present(result, json:, out:, err:)
|
|
135
297
|
if json
|
|
136
298
|
out.puts JSON.generate(result.to_h)
|
|
@@ -161,6 +323,8 @@ module Prdigest
|
|
|
161
323
|
|
|
162
324
|
def print_help(out)
|
|
163
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]"
|
|
327
|
+
out.puts " prdigest prose [--config PATH] [--date YYYY-MM-DD] [--repo owner/name] [--deliver]"
|
|
164
328
|
out.puts " prdigest serve | version"
|
|
165
329
|
0
|
|
166
330
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Prdigest
|
|
6
|
+
class Collector
|
|
7
|
+
def initialize(clock:, github:, repositories:, line_stats: false)
|
|
8
|
+
@clock = clock
|
|
9
|
+
@github = github
|
|
10
|
+
@repositories = Array(repositories).map { |repository| repository.to_s.freeze }.freeze
|
|
11
|
+
@line_stats = line_stats == true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(date:)
|
|
15
|
+
date = Date.iso8601(date.to_s)
|
|
16
|
+
window = @clock.window(date)
|
|
17
|
+
return empty_digest(date) if window.zero_length?
|
|
18
|
+
|
|
19
|
+
@github.fetch(
|
|
20
|
+
date: date,
|
|
21
|
+
window: window,
|
|
22
|
+
repositories: @repositories,
|
|
23
|
+
line_stats: @line_stats
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def empty_digest(date)
|
|
30
|
+
DayDigest.build(
|
|
31
|
+
date: date,
|
|
32
|
+
repository_order: @repositories,
|
|
33
|
+
pulls: [],
|
|
34
|
+
line_stats: @line_stats
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/prdigest/config.rb
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "ipaddr"
|
|
4
4
|
require "pathname"
|
|
5
5
|
require "tzinfo"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "yaml"
|
|
6
8
|
|
|
7
9
|
module Prdigest
|
|
8
10
|
class Config
|
|
9
11
|
attr_reader :raw, :path
|
|
10
12
|
|
|
11
13
|
REPOSITORY_SEGMENT = /\A[A-Za-z0-9_.-]+\z/
|
|
14
|
+
ENVIRONMENT_VARIABLE = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
15
|
+
IPV4_LOOPBACK = IPAddr.new("127.0.0.0/8").freeze
|
|
16
|
+
IPV6_LOOPBACK = IPAddr.new("::1").freeze
|
|
17
|
+
PROSE_BASE_URL_ERROR =
|
|
18
|
+
"prose.base_url must be an absolute HTTPS URL (or HTTP loopback URL) " \
|
|
19
|
+
"without credentials, query, or fragment"
|
|
12
20
|
|
|
13
21
|
def self.resolve_path(explicit: nil, env: ENV, system_path: "/etc/prdigest/config.yml")
|
|
14
22
|
return File.expand_path(explicit) if explicit && !explicit.to_s.empty?
|
|
@@ -20,14 +28,14 @@ module Prdigest
|
|
|
20
28
|
raise ConfigError, "config path is required (--config, PRDIGEST_CONFIG, or /etc/prdigest/config.yml)"
|
|
21
29
|
end
|
|
22
30
|
|
|
23
|
-
def self.load(path)
|
|
31
|
+
def self.load(path, capability: :run)
|
|
24
32
|
path = File.expand_path(path)
|
|
25
33
|
raise ConfigError, "config not found: #{path}" unless File.file?(path)
|
|
26
34
|
|
|
27
35
|
raw = YAML.safe_load_file(path, aliases: true)
|
|
28
36
|
raise ConfigError, "config root must be a mapping" unless raw.is_a?(Hash)
|
|
29
37
|
|
|
30
|
-
new(raw, path: path).tap
|
|
38
|
+
new(raw, path: path).tap { |config| config.validate!(capability: capability) }
|
|
31
39
|
rescue ConfigError
|
|
32
40
|
raise
|
|
33
41
|
rescue StandardError => e
|
|
@@ -50,6 +58,32 @@ module Prdigest
|
|
|
50
58
|
end.values.freeze
|
|
51
59
|
end
|
|
52
60
|
|
|
61
|
+
def self.parse_prose_base_uri(value)
|
|
62
|
+
uri = URI.parse(value.to_s.strip)
|
|
63
|
+
valid = %w[http https].include?(uri.scheme) &&
|
|
64
|
+
!uri.host.to_s.empty? &&
|
|
65
|
+
uri.userinfo.nil? &&
|
|
66
|
+
uri.query.nil? &&
|
|
67
|
+
uri.fragment.nil? &&
|
|
68
|
+
(uri.scheme == "https" || loopback_host?(uri.host))
|
|
69
|
+
raise ConfigError, PROSE_BASE_URL_ERROR unless valid
|
|
70
|
+
|
|
71
|
+
uri
|
|
72
|
+
rescue URI::InvalidURIError
|
|
73
|
+
raise ConfigError, PROSE_BASE_URL_ERROR
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.loopback_host?(host)
|
|
77
|
+
return true if host.casecmp("localhost").zero?
|
|
78
|
+
|
|
79
|
+
address = IPAddr.new(host)
|
|
80
|
+
(address.ipv4? && IPV4_LOOPBACK.include?(address)) ||
|
|
81
|
+
(address.ipv6? && address == IPV6_LOOPBACK)
|
|
82
|
+
rescue IPAddr::InvalidAddressError
|
|
83
|
+
false
|
|
84
|
+
end
|
|
85
|
+
private_class_method :loopback_host?
|
|
86
|
+
|
|
53
87
|
def initialize(raw, path: nil)
|
|
54
88
|
@raw = raw
|
|
55
89
|
@path = path
|
|
@@ -73,6 +107,26 @@ module Prdigest
|
|
|
73
107
|
env[env_name.to_s].to_s
|
|
74
108
|
end
|
|
75
109
|
|
|
110
|
+
def prose_provider
|
|
111
|
+
prose_config["provider"].to_s
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def prose_base_url
|
|
115
|
+
prose_config["base_url"].to_s.strip
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def prose_model
|
|
119
|
+
prose_config["model"].to_s.strip
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def prose_api_key_env
|
|
123
|
+
prose_config["api_key_env"].to_s.strip
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def prose_api_key(env = ENV)
|
|
127
|
+
env[prose_api_key_env].to_s
|
|
128
|
+
end
|
|
129
|
+
|
|
76
130
|
def chat_id
|
|
77
131
|
Integer(raw.dig("telegram", "chat_id"))
|
|
78
132
|
rescue TypeError, ArgumentError
|
|
@@ -115,7 +169,12 @@ module Prdigest
|
|
|
115
169
|
raise ConfigError, "schedule.max_catchup_days must be an integer from 1 to 30"
|
|
116
170
|
end
|
|
117
171
|
|
|
118
|
-
def validate!
|
|
172
|
+
def validate!(capability: :run)
|
|
173
|
+
capability = capability.to_sym
|
|
174
|
+
unless %i[facts run prose prose_delivery].include?(capability)
|
|
175
|
+
raise ArgumentError, "unknown configuration capability: #{capability}"
|
|
176
|
+
end
|
|
177
|
+
|
|
119
178
|
begin
|
|
120
179
|
TZInfo::Timezone.get(timezone)
|
|
121
180
|
rescue TZInfo::InvalidTimezoneIdentifier
|
|
@@ -125,12 +184,45 @@ module Prdigest
|
|
|
125
184
|
raise ConfigError, "schedule.max_catchup_days must be from 1 to 30"
|
|
126
185
|
end
|
|
127
186
|
repos
|
|
187
|
+
return self if capability == :facts
|
|
188
|
+
|
|
189
|
+
validate_prose! if %i[prose prose_delivery].include?(capability)
|
|
190
|
+
return self if capability == :prose
|
|
191
|
+
|
|
192
|
+
validate_telegram!
|
|
193
|
+
self
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
def prose_config
|
|
199
|
+
section = raw["prose"]
|
|
200
|
+
section.is_a?(Hash) ? section : {}
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def validate_prose!
|
|
204
|
+
unless raw["prose"].is_a?(Hash)
|
|
205
|
+
raise ConfigError, "prose must be a mapping"
|
|
206
|
+
end
|
|
207
|
+
if prose_config.key?("api_key")
|
|
208
|
+
raise ConfigError, "prose API key must be supplied through an environment variable"
|
|
209
|
+
end
|
|
210
|
+
unless prose_provider == "openai_compatible"
|
|
211
|
+
raise ConfigError, "prose.provider must be openai_compatible"
|
|
212
|
+
end
|
|
213
|
+
self.class.parse_prose_base_uri(prose_base_url)
|
|
214
|
+
raise ConfigError, "prose.model must not be blank" if prose_model.empty?
|
|
215
|
+
unless prose_api_key_env.match?(ENVIRONMENT_VARIABLE)
|
|
216
|
+
raise ConfigError, "prose.api_key_env must name an environment variable"
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def validate_telegram!
|
|
128
221
|
raise ConfigError, "telegram.chat_id is required" if chat_id.nil?
|
|
129
222
|
raise ConfigError, "telegram.chat_id_allowlist must not be empty" if chat_id_allowlist.empty?
|
|
130
223
|
unless chat_id_allowlist.include?(chat_id)
|
|
131
224
|
raise ConfigError, "telegram.chat_id must be present in chat_id_allowlist"
|
|
132
225
|
end
|
|
133
|
-
self
|
|
134
226
|
end
|
|
135
227
|
end
|
|
136
228
|
end
|
|
@@ -102,7 +102,7 @@ module Prdigest
|
|
|
102
102
|
@root = File.expand_path(root)
|
|
103
103
|
end
|
|
104
104
|
|
|
105
|
-
def with_checkpoint(date:, chat_id:, scope:, chunks:)
|
|
105
|
+
def with_checkpoint(date:, chat_id:, scope:, chunks: nil, chunk_factory: nil)
|
|
106
106
|
normalized_date = Date.iso8601(date.to_s)
|
|
107
107
|
ensure_root!
|
|
108
108
|
path = File.join(@root, "#{normalized_date}.json")
|
|
@@ -123,14 +123,15 @@ module Prdigest
|
|
|
123
123
|
date: normalized_date,
|
|
124
124
|
chat_id: Integer(chat_id),
|
|
125
125
|
scope: Array(scope).map(&:to_s),
|
|
126
|
-
chunks:
|
|
126
|
+
chunks: chunks,
|
|
127
|
+
chunk_factory: chunk_factory
|
|
127
128
|
)
|
|
128
129
|
fail_if_unavailable!(data)
|
|
129
130
|
yield Session.new(path: path, data: data)
|
|
130
131
|
ensure
|
|
131
132
|
lock.flock(File::LOCK_UN)
|
|
132
133
|
end
|
|
133
|
-
rescue
|
|
134
|
+
rescue Error
|
|
134
135
|
raise
|
|
135
136
|
rescue StandardError => e
|
|
136
137
|
raise StateError, "cannot access delivery checkpoint (#{e.class})"
|
|
@@ -158,12 +159,13 @@ module Prdigest
|
|
|
158
159
|
File.chmod(0o700, @root)
|
|
159
160
|
end
|
|
160
161
|
|
|
161
|
-
def load_or_create(path, date:, chat_id:, scope:, chunks:)
|
|
162
|
+
def load_or_create(path, date:, chat_id:, scope:, chunks:, chunk_factory:)
|
|
162
163
|
if File.file?(path)
|
|
163
164
|
data = JSON.parse(File.read(path))
|
|
164
165
|
validate!(data, date: date, chat_id: chat_id, scope: scope)
|
|
165
166
|
data
|
|
166
167
|
else
|
|
168
|
+
chunks = build_chunks(chunks, chunk_factory)
|
|
167
169
|
data = {
|
|
168
170
|
"schema" => SCHEMA,
|
|
169
171
|
"schema_version" => SCHEMA_VERSION,
|
|
@@ -191,6 +193,17 @@ module Prdigest
|
|
|
191
193
|
)
|
|
192
194
|
end
|
|
193
195
|
|
|
196
|
+
def build_chunks(chunks, chunk_factory)
|
|
197
|
+
both_missing = chunks.nil? && chunk_factory.nil?
|
|
198
|
+
both_present = !chunks.nil? && !chunk_factory.nil?
|
|
199
|
+
if both_missing || both_present
|
|
200
|
+
raise StateError, "delivery checkpoint requires exactly one payload source"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
source = chunk_factory ? chunk_factory.call : chunks
|
|
204
|
+
Array(source).map(&:to_s)
|
|
205
|
+
end
|
|
206
|
+
|
|
194
207
|
def validate!(data, date:, chat_id:, scope:)
|
|
195
208
|
expected = {
|
|
196
209
|
"schema" => SCHEMA,
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Prdigest
|
|
6
|
+
class Facts
|
|
7
|
+
SCHEMA = "prdigest-facts"
|
|
8
|
+
SCHEMA_VERSION = 1
|
|
9
|
+
|
|
10
|
+
def initialize(digest:, timezone:)
|
|
11
|
+
@digest = digest
|
|
12
|
+
@timezone = timezone.to_s.freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.failure(error_kind:, message:)
|
|
16
|
+
{
|
|
17
|
+
schema: SCHEMA,
|
|
18
|
+
schema_version: SCHEMA_VERSION,
|
|
19
|
+
status: "failure",
|
|
20
|
+
error: {
|
|
21
|
+
kind: error_kind.to_s,
|
|
22
|
+
message: message.to_s
|
|
23
|
+
},
|
|
24
|
+
digest: nil
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_h
|
|
29
|
+
{
|
|
30
|
+
schema: SCHEMA,
|
|
31
|
+
schema_version: SCHEMA_VERSION,
|
|
32
|
+
status: "success",
|
|
33
|
+
error: nil,
|
|
34
|
+
digest: serialize_digest
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def serialize_digest
|
|
41
|
+
{
|
|
42
|
+
date: @digest.date.iso8601,
|
|
43
|
+
timezone: @timezone,
|
|
44
|
+
line_stats: @digest.line_stats,
|
|
45
|
+
repository_order: @digest.repositories.map(&:name),
|
|
46
|
+
repositories: @digest.repositories.map { |repository| serialize_repository(repository) },
|
|
47
|
+
totals: {
|
|
48
|
+
pull_requests: @digest.total_prs,
|
|
49
|
+
additions: @digest.total_additions,
|
|
50
|
+
deletions: @digest.total_deletions,
|
|
51
|
+
commits: @digest.total_commits
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def serialize_repository(repository)
|
|
57
|
+
{
|
|
58
|
+
name: repository.name,
|
|
59
|
+
pull_requests: repository.pull_requests.map { |pull| serialize_pull(pull) }
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def serialize_pull(pull)
|
|
64
|
+
{
|
|
65
|
+
number: pull.number,
|
|
66
|
+
title: pull.title,
|
|
67
|
+
url: pull.url,
|
|
68
|
+
author: pull.author,
|
|
69
|
+
merged_at: pull.merged_at.utc.iso8601,
|
|
70
|
+
additions: pull.additions,
|
|
71
|
+
deletions: pull.deletions,
|
|
72
|
+
commits: pull.commits
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Prdigest
|
|
6
|
+
class FactsRunner
|
|
7
|
+
def initialize(config:, date: nil, repositories: nil, env: ENV, clock: nil, github: nil)
|
|
8
|
+
@config = config
|
|
9
|
+
@date = date && Date.iso8601(date.to_s)
|
|
10
|
+
@repositories = repositories || config.repos
|
|
11
|
+
@clock = clock || Clock.new(timezone: config.timezone)
|
|
12
|
+
@github = github || GitHub.new(token: config.github_token(env))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
date = @date || @clock.yesterday
|
|
17
|
+
digest = Collector.new(
|
|
18
|
+
clock: @clock,
|
|
19
|
+
github: @github,
|
|
20
|
+
repositories: @repositories,
|
|
21
|
+
line_stats: @config.line_stats?
|
|
22
|
+
).call(date: date)
|
|
23
|
+
Facts.new(digest: digest, timezone: @config.timezone).to_h
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def inspect
|
|
27
|
+
"#<#{self.class} date=#{@date || "yesterday"} credentials=[REDACTED]>"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
alias to_s inspect
|
|
31
|
+
end
|
|
32
|
+
end
|