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.
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.
@@ -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
- config = Config.load(path)
46
+ capability = config_capability(parsed)
47
+ config = Config.load(path, capability: capability)
35
48
  if parsed[:command] == "serve"
36
- err.puts "prdigest serve: deferred in v0.1.x — use systemd timer + `prdigest run`"
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 = { command: nil, config: nil, date: nil, dry_run: false, json: false, repos: [] }
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
@@ -1,14 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "yaml"
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(&:validate!)
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: Array(chunks).map(&:to_s)
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 SendError, StateError
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