Class: Prdigest::Config

Inherits:
Object
  • Object
show all
Defined in:
lib/prdigest/config.rb

Constant Summary collapse

REPOSITORY_SEGMENT =
/\A[A-Za-z0-9_.-]+\z/
ENVIRONMENT_VARIABLE =
/\A[A-Za-z_][A-Za-z0-9_]*\z/
IPV4_LOOPBACK =
IPAddr.new("127.0.0.0/8").freeze
IPV6_LOOPBACK =
IPAddr.new("::1").freeze
PROSE_BASE_URL_ERROR =
"prose.base_url must be an absolute HTTPS URL (or HTTP loopback URL) " \
"without credentials, query, or fragment"

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(raw, path: nil) ⇒ Config

Returns a new instance of Config.



87
88
89
90
# File 'lib/prdigest/config.rb', line 87

def initialize(raw, path: nil)
  @raw = raw
  @path = path
end

Instance Attribute Details

#pathObject (readonly)

Returns the value of attribute path.



11
12
13
# File 'lib/prdigest/config.rb', line 11

def path
  @path
end

#rawObject (readonly)

Returns the value of attribute raw.



11
12
13
# File 'lib/prdigest/config.rb', line 11

def raw
  @raw
end

Class Method Details

.load(path, capability: :run) ⇒ Object



31
32
33
34
35
36
37
38
39
40
41
42
43
# File 'lib/prdigest/config.rb', line 31

def self.load(path, capability: :run)
  path = File.expand_path(path)
  raise ConfigError, "config not found: #{path}" unless File.file?(path)

  raw = YAML.safe_load_file(path, aliases: true)
  raise ConfigError, "config root must be a mapping" unless raw.is_a?(Hash)

  new(raw, path: path).tap { |config| config.validate!(capability: capability) }
rescue ConfigError
  raise
rescue StandardError => e
  raise ConfigError, "cannot load config: #{e.class}"
end

.normalize_repos(values, label: "repository") ⇒ Object

Raises:



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/prdigest/config.rb', line 45

def self.normalize_repos(values, label: "repository")
  normalized = Array(values).map { |value| value.to_s.strip }
  raise ConfigError, "#{label} must list at least one owner/name" if normalized.empty?

  invalid = normalized.find do |repository|
    parts = repository.split("/", -1)
    parts.length != 2 ||
      parts.any? { |part| part.empty? || %w[. ..].include?(part) || !part.match?(REPOSITORY_SEGMENT) }
  end
  raise ConfigError, "#{label} entry must be owner/name; got #{invalid.inspect}" if invalid

  normalized.each_with_object({}) do |repository, unique|
    unique[repository.downcase] ||= repository
  end.values.freeze
end

.parse_prose_base_uri(value) ⇒ Object



61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/prdigest/config.rb', line 61

def self.parse_prose_base_uri(value)
  uri = URI.parse(value.to_s.strip)
  valid = %w[http https].include?(uri.scheme) &&
    !uri.host.to_s.empty? &&
    uri.userinfo.nil? &&
    uri.query.nil? &&
    uri.fragment.nil? &&
    (uri.scheme == "https" || loopback_host?(uri.host))
  raise ConfigError, PROSE_BASE_URL_ERROR unless valid

  uri
rescue URI::InvalidURIError
  raise ConfigError, PROSE_BASE_URL_ERROR
end

.resolve_path(explicit: nil, env: ENV, system_path: "/etc/prdigest/config.yml") ⇒ Object

Raises:



21
22
23
24
25
26
27
28
29
# File 'lib/prdigest/config.rb', line 21

def self.resolve_path(explicit: nil, env: ENV, system_path: "/etc/prdigest/config.yml")
  return File.expand_path(explicit) if explicit && !explicit.to_s.empty?

  from_env = env["PRDIGEST_CONFIG"].to_s
  return File.expand_path(from_env) unless from_env.empty?
  return system_path if File.file?(system_path)

  raise ConfigError, "config path is required (--config, PRDIGEST_CONFIG, or /etc/prdigest/config.yml)"
end

Instance Method Details

#chat_idObject



130
131
132
133
134
# File 'lib/prdigest/config.rb', line 130

def chat_id
  Integer(raw.dig("telegram", "chat_id"))
rescue TypeError, ArgumentError
  nil
end

#chat_id_allowlistObject



136
137
138
139
140
# File 'lib/prdigest/config.rb', line 136

def chat_id_allowlist
  Array(raw.dig("telegram", "chat_id_allowlist")).map { |id| Integer(id) }
rescue TypeError, ArgumentError
  []
end

#delivery_state_pathObject



158
159
160
# File 'lib/prdigest/config.rb', line 158

def delivery_state_path
  raw.dig("state", "delivery_path") || File.join(File.dirname(state_path), "deliveries")
end

#empty_messageObject



150
151
152
# File 'lib/prdigest/config.rb', line 150

def empty_message
  raw.dig("digest", "empty_message") || "Merged PR digest — {date}\nTotal: 0 PRs"
end

#github_token(env = ENV) ⇒ Object



100
101
102
103
# File 'lib/prdigest/config.rb', line 100

def github_token(env = ENV)
  env_name = raw.dig("github", "token_env") || "GITHUB_TOKEN"
  env[env_name.to_s].to_s
end

#line_stats?Boolean

Returns:

  • (Boolean)


142
143
144
# File 'lib/prdigest/config.rb', line 142

def line_stats?
  raw.dig("digest", "line_stats") != false
end

#max_catchup_daysObject



166
167
168
169
170
# File 'lib/prdigest/config.rb', line 166

def max_catchup_days
  Integer(raw.dig("schedule", "max_catchup_days") || 7)
rescue TypeError, ArgumentError
  raise ConfigError, "schedule.max_catchup_days must be an integer from 1 to 30"
end

#prose_api_key(env = ENV) ⇒ Object



126
127
128
# File 'lib/prdigest/config.rb', line 126

def prose_api_key(env = ENV)
  env[prose_api_key_env].to_s
end

#prose_api_key_envObject



122
123
124
# File 'lib/prdigest/config.rb', line 122

def prose_api_key_env
  prose_config["api_key_env"].to_s.strip
end

#prose_base_urlObject



114
115
116
# File 'lib/prdigest/config.rb', line 114

def prose_base_url
  prose_config["base_url"].to_s.strip
end

#prose_modelObject



118
119
120
# File 'lib/prdigest/config.rb', line 118

def prose_model
  prose_config["model"].to_s.strip
end

#prose_providerObject



110
111
112
# File 'lib/prdigest/config.rb', line 110

def prose_provider
  prose_config["provider"].to_s
end

#reposObject



96
97
98
# File 'lib/prdigest/config.rb', line 96

def repos
  self.class.normalize_repos(raw.dig("github", "repos"), label: "github.repos")
end

#schedule_cronObject



162
163
164
# File 'lib/prdigest/config.rb', line 162

def schedule_cron
  raw.dig("schedule", "cron") || "5 9 * * *"
end

#send_empty?Boolean

Returns:

  • (Boolean)


146
147
148
# File 'lib/prdigest/config.rb', line 146

def send_empty?
  raw.dig("digest", "send_empty") != false
end

#state_pathObject



154
155
156
# File 'lib/prdigest/config.rb', line 154

def state_path
  raw.dig("state", "path") || File.expand_path("~/.local/share/prdigest/state.json")
end

#telegram_token(env = ENV) ⇒ Object



105
106
107
108
# File 'lib/prdigest/config.rb', line 105

def telegram_token(env = ENV)
  env_name = raw.dig("telegram", "token_env") || "TELEGRAM_BOT_TOKEN"
  env[env_name.to_s].to_s
end

#timezoneObject



92
93
94
# File 'lib/prdigest/config.rb', line 92

def timezone
  raw.fetch("timezone", "UTC").to_s
end

#validate!(capability: :run) ⇒ Object



172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/prdigest/config.rb', line 172

def validate!(capability: :run)
  capability = capability.to_sym
  unless %i[facts run prose prose_delivery].include?(capability)
    raise ArgumentError, "unknown configuration capability: #{capability}"
  end

  begin
    TZInfo::Timezone.get(timezone)
  rescue TZInfo::InvalidTimezoneIdentifier
    raise ConfigError, "timezone must be a resolvable IANA identifier"
  end
  unless (1..30).cover?(max_catchup_days)
    raise ConfigError, "schedule.max_catchup_days must be from 1 to 30"
  end
  repos
  return self if capability == :facts

  validate_prose! if %i[prose prose_delivery].include?(capability)
  return self if capability == :prose

  validate_telegram!
  self
end