snitcher_reports 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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/.snitcher.yml.example +135 -0
  3. data/CHANGELOG.md +9 -0
  4. data/LICENSE +21 -0
  5. data/README.md +685 -0
  6. data/lib/snitcher/anomaly.rb +24 -0
  7. data/lib/snitcher/anomaly_context.rb +76 -0
  8. data/lib/snitcher/anomaly_detector.rb +32 -0
  9. data/lib/snitcher/anomaly_rules/base.rb +29 -0
  10. data/lib/snitcher/anomaly_rules/commits_without_closed_tickets.rb +22 -0
  11. data/lib/snitcher/anomaly_rules/estimate_overrun.rb +37 -0
  12. data/lib/snitcher/anomaly_rules/excessive_meeting_time.rb +43 -0
  13. data/lib/snitcher/anomaly_rules/inactive_working_days.rb +32 -0
  14. data/lib/snitcher/anomaly_rules/time_above_availability.rb +27 -0
  15. data/lib/snitcher/client.rb +177 -0
  16. data/lib/snitcher/configuration.rb +123 -0
  17. data/lib/snitcher/holiday_region_resolver.rb +123 -0
  18. data/lib/snitcher/jql_validator.rb +44 -0
  19. data/lib/snitcher/meetings_aggregator.rb +38 -0
  20. data/lib/snitcher/orchestrator.rb +227 -0
  21. data/lib/snitcher/presenters/html.rb +87 -0
  22. data/lib/snitcher/presenters/markdown.rb +140 -0
  23. data/lib/snitcher/presenters/presenter.rb +90 -0
  24. data/lib/snitcher/report.rb +112 -0
  25. data/lib/snitcher/report_service.rb +128 -0
  26. data/lib/snitcher/report_writer.rb +74 -0
  27. data/lib/snitcher/sources/clickup/archived_lists.rb +174 -0
  28. data/lib/snitcher/sources/clickup/working_hours_filter.rb +88 -0
  29. data/lib/snitcher/sources/clickup.rb +467 -0
  30. data/lib/snitcher/sources/git/command_runner.rb +198 -0
  31. data/lib/snitcher/sources/git.rb +347 -0
  32. data/lib/snitcher/sources/google_calendar.rb +328 -0
  33. data/lib/snitcher/sources/jira/client.rb +346 -0
  34. data/lib/snitcher/sources/jira/status_categories.rb +60 -0
  35. data/lib/snitcher/sources/jira.rb +523 -0
  36. data/lib/snitcher/sources/linear.rb +257 -0
  37. data/lib/snitcher/sources/source.rb +140 -0
  38. data/lib/snitcher/templates/report.html.erb +564 -0
  39. data/lib/snitcher/templates/snitcher-logo.png +0 -0
  40. data/lib/snitcher/time_formatter.rb +27 -0
  41. data/lib/snitcher/validators.rb +59 -0
  42. data/lib/snitcher/version.rb +5 -0
  43. data/lib/snitcher/work_item.rb +75 -0
  44. data/lib/snitcher/working_hours_calculator.rb +205 -0
  45. data/lib/snitcher/working_hours_config.rb +208 -0
  46. data/lib/snitcher.rb +39 -0
  47. metadata +250 -0
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+
6
+ module Snitcher
7
+ # Normalized work item from any source.
8
+ # Supports NDJSON serialization for Unix-style piping between commands.
9
+ WorkItem = Data.define(
10
+ :source, # Symbol: :jira, :git, :linear, :clickup, :google_calendar
11
+ :external_id, # String: "PROJ-123", "abc123", "ENG-456"
12
+ :title, # String: issue/PR title
13
+ :completed_at, # Time or nil
14
+ :time_spent_seconds, # Integer or nil
15
+ :time_source, # Symbol: :worklog, :calculated, :estimate, nil
16
+ :url, # String or nil: link to issue/PR
17
+ :metadata # Hash: source-specific extra data
18
+ ) do
19
+ def time_spent_human
20
+ return nil unless time_spent_seconds
21
+
22
+ TimeFormatter.format_duration(time_spent_seconds)
23
+ end
24
+
25
+ def completed_date
26
+ completed_at&.strftime('%Y-%m-%d')
27
+ end
28
+
29
+ # Serialize to a plain Hash suitable for JSON output
30
+ def to_h
31
+ {
32
+ source: source,
33
+ external_id: external_id,
34
+ title: title,
35
+ completed_at: completed_at&.iso8601,
36
+ time_spent_seconds: time_spent_seconds,
37
+ time_source: time_source,
38
+ url: url,
39
+ metadata: metadata
40
+ }
41
+ end
42
+
43
+ def to_json(...)
44
+ to_h.to_json(...)
45
+ end
46
+
47
+ # Deserialize from a Hash (as parsed from JSON)
48
+ def self.from_h(hash)
49
+ new(
50
+ source: hash['source']&.to_sym,
51
+ external_id: hash['external_id'],
52
+ title: hash['title'],
53
+ completed_at: hash['completed_at'] ? Time.parse(hash['completed_at']) : nil,
54
+ time_spent_seconds: hash['time_spent_seconds'],
55
+ time_source: hash['time_source']&.to_sym,
56
+ url: hash['url'],
57
+ metadata: hash['metadata'] || {}
58
+ )
59
+ end
60
+
61
+ # Parse NDJSON lines (one JSON object per line) into WorkItem array.
62
+ # Malformed lines are skipped with a warning to support robust piping.
63
+ def self.from_ndjson(text)
64
+ text.lines.filter_map do |line|
65
+ line = line.strip
66
+ next if line.empty?
67
+
68
+ from_h(JSON.parse(line))
69
+ rescue JSON::ParserError => e
70
+ warn "[snitcher] Skipping malformed NDJSON line: #{e.message}"
71
+ nil
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'holidays'
4
+ require 'tzinfo'
5
+
6
+ module Snitcher
7
+ # Calculates working seconds between two UTC timestamps,
8
+ # filtering to configured business hours and work days.
9
+ #
10
+ # When no working hours config is provided, returns raw wall-clock seconds
11
+ # (backward compatible with existing behavior).
12
+ #
13
+ # @example
14
+ # config = { timezone: 'Europe/Warsaw', start_hour: '09:00',
15
+ # end_hour: '17:00', work_days: [1, 2, 3, 4, 5],
16
+ # holidays_region: 'pl' }
17
+ # WorkingHoursCalculator.seconds_between(start_utc, end_utc, config)
18
+ #
19
+ module WorkingHoursCalculator
20
+ # Monday through Friday, using Ruby's Date#wday convention (0=Sun, 6=Sat).
21
+ DEFAULT_WORK_DAYS = [1, 2, 3, 4, 5].freeze
22
+
23
+ # Maximum days to iterate (~2 years) before falling back to raw seconds (safety cap).
24
+ MAX_ITERATION_DAYS = 730
25
+
26
+ # HH:MM format regex for time validation.
27
+ TIME_FORMAT = /\A\d{2}:\d{2}\z/
28
+
29
+ # Reusable empty set for when no holidays region is configured.
30
+ EMPTY_HOLIDAY_SET = Set.new.freeze
31
+
32
+ class << self
33
+ # Calculate working seconds between two UTC timestamps.
34
+ #
35
+ # @param start_time [Time] UTC start timestamp
36
+ # @param end_time [Time] UTC end timestamp
37
+ # @param working_hours [Hash, nil] working hours config with symbol keys:
38
+ # :timezone, :start_hour, :end_hour, :work_days, :holidays_region (optional)
39
+ # @return [Integer] seconds within working hours, or raw seconds if config nil.
40
+ # Returns negative when config is nil and end_time < start_time (raw subtraction).
41
+ # Returns 0 when config is present and end_time <= start_time.
42
+ def seconds_between(start_time, end_time, working_hours = nil)
43
+ return (end_time - start_time).to_i if working_hours.nil?
44
+ return 0 if end_time <= start_time
45
+
46
+ parsed = parsed_config(working_hours)
47
+ return 0 unless parsed
48
+
49
+ calculate(start_time, end_time, **parsed)
50
+ end
51
+
52
+ # Validates an HH:MM time string format and hour/minute ranges.
53
+ #
54
+ # @param value [String] time string to validate
55
+ # @return [Boolean] true if valid HH:MM with hour 0-23 and minute 0-59
56
+ # @example
57
+ # WorkingHoursCalculator.valid_time_format?('09:00') # => true
58
+ # WorkingHoursCalculator.valid_time_format?('25:00') # => false
59
+ def valid_time_format?(value)
60
+ return false unless value.is_a?(String) && value.match?(TIME_FORMAT)
61
+
62
+ h, m = value.split(':').map(&:to_i)
63
+ (0..23).cover?(h) && (0..59).cover?(m)
64
+ end
65
+
66
+ # Whether start_hour is strictly before end_hour (night shifts not supported).
67
+ # The single source of truth for the check #parse_config also relies on.
68
+ # Assumes HH:MM input: anything else counts as 00:00, so check
69
+ # valid_time_format? first when the values are not yet validated.
70
+ #
71
+ # @param start_hour [String] HH:MM
72
+ # @param end_hour [String] HH:MM
73
+ # @return [Boolean]
74
+ # @example
75
+ # WorkingHoursCalculator.ordered?(start_hour: '09:00', end_hour: '17:00') # => true
76
+ # WorkingHoursCalculator.ordered?(start_hour: '17:00', end_hour: '09:00') # => false
77
+ def ordered?(start_hour:, end_hour:)
78
+ minutes_of(start_hour) < minutes_of(end_hour)
79
+ end
80
+
81
+ # Resets module-level caches. Call in test setup to prevent cross-test leakage.
82
+ def reset_cache!
83
+ @parsed_by_config = nil
84
+ @holiday_set_cache = nil
85
+ end
86
+
87
+ private
88
+
89
+ # Parsed configs, keyed by the config hash. A batch report resolves one config per
90
+ # person and Configuration memoizes them, so this is bounded by the number of people
91
+ # in the run. key? rather than ||= because parse_config returns nil for inverted
92
+ # hours, and that answer is worth caching too.
93
+ # NOTE: Not thread-safe; acceptable because the CLI is single-threaded.
94
+ def parsed_config(working_hours)
95
+ cache = (@parsed_by_config ||= {})
96
+ return cache[working_hours] if cache.key?(working_hours)
97
+
98
+ cache[working_hours] = parse_config(working_hours)
99
+ end
100
+
101
+ # Parses working_hours config hash into internal typed representation.
102
+ # @param working_hours [Hash] config with symbol keys
103
+ # (:timezone, :start_hour, :end_hour, :work_days, :holidays_region)
104
+ # @return [Hash, nil] parsed config, or nil if start >= end (inverted hours)
105
+ def parse_config(working_hours)
106
+ start_hour = working_hours[:start_hour].to_s
107
+ end_hour = working_hours[:end_hour].to_s
108
+ return nil unless ordered?(start_hour: start_hour, end_hour: end_hour)
109
+
110
+ timezone = TZInfo::Timezone.get(working_hours[:timezone])
111
+ work_days = working_hours[:work_days] || DEFAULT_WORK_DAYS
112
+ start_h, start_m = start_hour.split(':').map(&:to_i)
113
+ end_h, end_m = end_hour.split(':').map(&:to_i)
114
+
115
+ { timezone: timezone, work_days: work_days,
116
+ day_start_h: start_h || 0, day_start_m: start_m || 0,
117
+ day_end_h: end_h || 0, day_end_m: end_m || 0,
118
+ holidays_region: working_hours[:holidays_region]&.to_sym }
119
+ end
120
+
121
+ # @param hh_mm [String] HH:MM
122
+ # @return [Integer] minutes since midnight
123
+ def minutes_of(hh_mm)
124
+ h, m = hh_mm.to_s.split(':').map(&:to_i)
125
+ (h.to_i * 60) + m.to_i
126
+ end
127
+
128
+ # Iterates day-by-day from start to end, accumulating seconds that fall
129
+ # within the configured work-day window (clamped to actual start/end bounds).
130
+ # rubocop:disable Metrics/ParameterLists
131
+ def calculate(start_time, end_time, timezone:, work_days:, day_start_h:, day_start_m:, day_end_h:, day_end_m:,
132
+ holidays_region: nil)
133
+ # rubocop:enable Metrics/ParameterLists
134
+ local_start = timezone.to_local(start_time)
135
+ local_end = timezone.to_local(end_time)
136
+ end_date = local_end.to_date
137
+
138
+ day_span = (end_date - local_start.to_date).to_i
139
+ if day_span > MAX_ITERATION_DAYS
140
+ warn "[Snitcher] Warning: time range spans #{day_span} days (> #{MAX_ITERATION_DAYS}), " \
141
+ 'falling back to raw seconds.'
142
+ return (end_time - start_time).to_i
143
+ end
144
+
145
+ holiday_dates = build_holiday_set(local_start.to_date, end_date, holidays_region)
146
+
147
+ total = 0
148
+ current_date = local_start.to_date
149
+
150
+ while current_date <= end_date
151
+ if work_days.include?(current_date.wday) && !holiday_dates.include?(current_date)
152
+ work_day_start_utc = safe_local_to_utc(timezone, current_date, day_start_h, day_start_m)
153
+ work_day_end_utc = safe_local_to_utc(timezone, current_date, day_end_h, day_end_m)
154
+
155
+ effective_start = [start_time, work_day_start_utc].max
156
+ effective_end = [end_time, work_day_end_utc].min
157
+
158
+ total += (effective_end - effective_start).to_i if effective_end > effective_start
159
+ end
160
+ current_date = current_date.next_day
161
+ end
162
+
163
+ total
164
+ end
165
+
166
+ # Pre-fetches all public holidays in the date range and returns a Set for O(1) lookup.
167
+ # Results are cached by region — subsequent calls with the same region and overlapping
168
+ # date range reuse the cached set (avoids N Holidays.between calls for N issues).
169
+ # @param start_date [Date] range start
170
+ # @param end_date [Date] range end
171
+ # @param region [Symbol, nil] holidays gem region (e.g. :pl)
172
+ # @return [Set] set of Date objects that are public holidays (empty if no region)
173
+ def build_holiday_set(start_date, end_date, region)
174
+ return EMPTY_HOLIDAY_SET if region.nil?
175
+
176
+ cache = (@holiday_set_cache ||= {})
177
+ cached = cache[region]
178
+ return cached[:set] if cached && cached[:start_date] <= start_date && cached[:end_date] >= end_date
179
+
180
+ holiday_set = Holidays.between(start_date, end_date, region, :observed)
181
+ .to_set { |h| h[:date] }
182
+
183
+ cache[region] = { start_date: start_date, end_date: end_date, set: holiday_set }
184
+ holiday_set
185
+ end
186
+
187
+ # Safely convert local time to UTC, handling DST ambiguity and gaps.
188
+ # For ambiguous times (fall-back): picks the first (DST) occurrence.
189
+ # For gap times (spring-forward): shifts forward past the gap using time arithmetic.
190
+ # Assumes standard 1-hour DST gap (covers all current IANA zones).
191
+ def safe_local_to_utc(timezone, date, hour, minute)
192
+ wall_clock = Time.utc(date.year, date.month, date.day, hour, minute, 0)
193
+ timezone.local_to_utc(wall_clock, &:first)
194
+ rescue TZInfo::PeriodNotFound
195
+ shifted = wall_clock + 3600
196
+ begin
197
+ timezone.local_to_utc(Time.utc(shifted.year, shifted.month, shifted.day,
198
+ shifted.hour, shifted.min, 0), &:first)
199
+ rescue TZInfo::PeriodNotFound
200
+ shifted # Exotic gap > 1 hour — best-effort: use shifted UTC time directly
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Snitcher
4
+ # Parses the top-level `working_hours` block and resolves it per person.
5
+ #
6
+ # The block's own keys are the shared defaults. A `people:` map, keyed by the work
7
+ # email a report is requested for, overrides individual keys for one person; every
8
+ # key that person leaves out is inherited. Omitting the block, or naming nobody in
9
+ # it, keeps the historical contract: nil, meaning raw wall-clock time.
10
+ #
11
+ # @example
12
+ # working_hours:
13
+ # timezone: "Europe/Warsaw"
14
+ # start_hour: "09:00"
15
+ # end_hour: "17:00"
16
+ # people:
17
+ # bob@corp.com:
18
+ # timezone: "America/New_York"
19
+ #
20
+ class WorkingHoursConfig
21
+ # Holds the per-person overrides. Stripped before the shared block is validated,
22
+ # the same way Orchestrator strips :user/:emails before constructing a source.
23
+ PEOPLE_KEY = 'people'
24
+
25
+ # Keys a person may override. Anything else in their block is dropped, so a typo
26
+ # cannot smuggle an unrecognized key into an otherwise validated config.
27
+ OVERRIDABLE_KEYS = %w[timezone start_hour end_hour work_days holidays_region].freeze
28
+
29
+ def initialize(raw)
30
+ @raw = raw.is_a?(Hash) ? raw : {}
31
+ @resolved = {}
32
+ end
33
+
34
+ # The hours that apply to one person: their overrides merged over the shared block,
35
+ # or the shared block when they are not named. Memoized per email — repeated calls
36
+ # return the same object, which keeps WorkingHoursCalculator's cache hitting.
37
+ # @param user_email [String, nil] requested work email; nil asks for the shared block
38
+ # @return [Hash, nil] validated config with symbolized keys, nil when unconfigured
39
+ def for(user_email = nil)
40
+ key = normalize_key(user_email)
41
+ return @resolved[key] if @resolved.key?(key)
42
+
43
+ @resolved[key] = resolve(key)
44
+ end
45
+
46
+ # The same value, but only when this person is actually named in `people:` and their
47
+ # block was usable. Orchestrator needs to tell "this person is different" from
48
+ # "everyone gets the shared block", so it can leave a source's injected config alone.
49
+ # @return [Hash, nil]
50
+ def override_for(user_email)
51
+ key = normalize_key(user_email)
52
+ return nil unless key && people.key?(key)
53
+
54
+ resolved = self.for(user_email)
55
+ # A person whose block failed validation resolves to the shared object itself.
56
+ resolved.equal?(defaults) ? nil : resolved
57
+ end
58
+
59
+ private
60
+
61
+ def normalize_key(user_email)
62
+ email = user_email.to_s.strip
63
+ email.empty? ? nil : email
64
+ end
65
+
66
+ def resolve(key)
67
+ # Parse the shared block first even for a person who overrides every key, so a
68
+ # broken shared block is reported once instead of only for whoever lacks an entry.
69
+ base = defaults
70
+ overrides = key && people[key]
71
+ return base if overrides.nil? || overrides.empty?
72
+
73
+ parse(shared_block.merge(overrides), person: key) || base
74
+ end
75
+
76
+ def defaults
77
+ return @defaults if defined?(@defaults)
78
+
79
+ @defaults = parse(shared_block)
80
+ end
81
+
82
+ def shared_block
83
+ @shared_block ||= @raw.reject { |key, _| key.to_s == PEOPLE_KEY }
84
+ end
85
+
86
+ def people
87
+ @people ||= normalize_people(@raw[PEOPLE_KEY])
88
+ end
89
+
90
+ # Coerces the map to { String => Hash } of recognized keys only. Keys are not
91
+ # validated as emails: sources.<name>.emails does not validate its keys either, and
92
+ # an address that matches nobody simply never resolves.
93
+ def normalize_people(raw)
94
+ return {} if raw.nil?
95
+
96
+ unless raw.is_a?(Hash)
97
+ warn '[Snitcher] Warning: working_hours people must be a map of email => overrides. Ignoring.'
98
+ return {}
99
+ end
100
+
101
+ raw.each_with_object({}) do |(email, overrides), map|
102
+ key = email.to_s.strip
103
+ next if key.empty?
104
+
105
+ unless overrides.is_a?(Hash)
106
+ warn "[Snitcher] Warning: working_hours people entry for #{key} must be a map. Ignoring."
107
+ next
108
+ end
109
+
110
+ map[key] = overrides.to_h { |k, v| [k.to_s, v] }.slice(*OVERRIDABLE_KEYS)
111
+ end
112
+ end
113
+
114
+ # Validates one fully merged block. Returns nil — meaning "fall back" — for anything
115
+ # unusable, warning first. Nothing here raises: a config file must never abort a report.
116
+ def parse(block, person: nil)
117
+ return nil if block.empty?
118
+
119
+ validated = validate_fields(block, person)
120
+ return nil unless validated
121
+
122
+ Validators.validate_timezone!(validated[:timezone])
123
+ validated
124
+ rescue Validators::ValidationError => e
125
+ # e.message is already a sentence ("Unknown timezone: Foo/Bar"), so this one does not
126
+ # take the scope prefix the other warnings use.
127
+ scope = person ? " in working_hours for #{person}" : ''
128
+ outcome = person ? 'Using the shared working hours.' : 'Working hours disabled.'
129
+ warn "[Snitcher] Warning: #{e.message}#{scope}. #{outcome}"
130
+ nil
131
+ end
132
+
133
+ def validate_fields(block, person)
134
+ if block['timezone'].nil? || block['start_hour'].nil? || block['end_hour'].nil?
135
+ warn_config('config incomplete (needs timezone, start_hour, end_hour).', fallback_note(person), person)
136
+ return nil
137
+ end
138
+
139
+ start_hour = block['start_hour'].to_s
140
+ end_hour = block['end_hour'].to_s
141
+ return nil unless valid_hours?(block, start_hour, end_hour, person)
142
+
143
+ { timezone: block['timezone'], start_hour: start_hour, end_hour: end_hour,
144
+ work_days: validated_work_days(block['work_days'], person),
145
+ holidays_region: validated_holidays_region(block['holidays_region'], person) }
146
+ end
147
+
148
+ # Format first, then order: each rule fails with its own warning.
149
+ def valid_hours?(block, start_hour, end_hour, person)
150
+ unless WorkingHoursCalculator.valid_time_format?(start_hour) &&
151
+ WorkingHoursCalculator.valid_time_format?(end_hour)
152
+ problem = if block['start_hour'].is_a?(Integer) || block['end_hour'].is_a?(Integer)
153
+ 'start_hour/end_hour must be quoted strings in YAML (e.g., "09:00").'
154
+ else
155
+ 'start_hour/end_hour must be HH:MM format.'
156
+ end
157
+ warn_config(problem, fallback_note(person), person)
158
+ return false
159
+ end
160
+
161
+ return true if WorkingHoursCalculator.ordered?(start_hour: start_hour, end_hour: end_hour)
162
+
163
+ warn_config("start_hour '#{start_hour}' must be before end_hour '#{end_hour}' (night shifts not supported).",
164
+ fallback_note(person), person)
165
+ false
166
+ end
167
+
168
+ def valid_work_days?(work_days)
169
+ work_days.is_a?(Array) && work_days.all? { |d| d.is_a?(Integer) && (0..6).cover?(d) }
170
+ end
171
+
172
+ def validated_work_days(work_days, person)
173
+ if work_days && !valid_work_days?(work_days)
174
+ warn_config('work_days must be array of integers 0-6.', 'Using default Mon-Fri.', person)
175
+ return WorkingHoursCalculator::DEFAULT_WORK_DAYS
176
+ end
177
+
178
+ work_days || WorkingHoursCalculator::DEFAULT_WORK_DAYS
179
+ end
180
+
181
+ # Validates holidays_region against the holidays gem's known regions.
182
+ def validated_holidays_region(region, person)
183
+ return nil if region.nil?
184
+
185
+ region_str = region.to_s.strip
186
+ return nil if region_str.empty?
187
+
188
+ unless HolidayRegionResolver.valid_region?(region_str)
189
+ warn_config("unknown holidays region '#{region_str}'.", 'Holiday exclusion disabled.', person)
190
+ return nil
191
+ end
192
+
193
+ region_str
194
+ end
195
+
196
+ # Every warning names the block it came from, and a person's warning names them.
197
+ def warn_config(problem, outcome, person)
198
+ scope = person ? "working_hours for #{person}" : 'working_hours'
199
+ warn "[Snitcher] Warning: #{scope} #{problem} #{outcome}"
200
+ end
201
+
202
+ # A broken shared block disables working hours entirely; a broken person block only
203
+ # sends that person back to the shared one.
204
+ def fallback_note(person)
205
+ person ? 'Using the shared working hours.' : 'Working hours disabled; raw wall-clock time is used.'
206
+ end
207
+ end
208
+ end
data/lib/snitcher.rb ADDED
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zeitwerk'
4
+ # NOTE: dotenv is deliberately not loaded here — an embedded core must take the host
5
+ # application's ENV as-is.
6
+
7
+ # NOTE: json is required for the host, not for this file — the public payload is a plain
8
+ # Hash and `payload.to_json` is its documented serialization, so Hash#to_json has to exist
9
+ # after `require "snitcher"`. The suite cannot catch its removal: webmock loads json first.
10
+ require 'json'
11
+
12
+ module Snitcher
13
+ class Error < StandardError; end
14
+ class ConfigurationError < Error; end
15
+
16
+ class << self
17
+ # Entry point for embedding Snitcher in a host application.
18
+ # @return [Snitcher::Client]
19
+ def new(config_path: Configuration::DEFAULT_CONFIG_FILE)
20
+ Client.new(config_path: config_path)
21
+ end
22
+
23
+ def root
24
+ @root ||= Pathname.new(File.expand_path('..', __dir__))
25
+ end
26
+
27
+ def loader
28
+ @loader ||= Zeitwerk::Loader.for_gem.tap do |ldr|
29
+ ldr.inflector.inflect(
30
+ 'clickup' => 'ClickUp',
31
+ 'jql_validator' => 'JQLValidator'
32
+ )
33
+ ldr.setup
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ Snitcher.loader