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.
- checksums.yaml +7 -0
- data/.snitcher.yml.example +135 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE +21 -0
- data/README.md +685 -0
- data/lib/snitcher/anomaly.rb +24 -0
- data/lib/snitcher/anomaly_context.rb +76 -0
- data/lib/snitcher/anomaly_detector.rb +32 -0
- data/lib/snitcher/anomaly_rules/base.rb +29 -0
- data/lib/snitcher/anomaly_rules/commits_without_closed_tickets.rb +22 -0
- data/lib/snitcher/anomaly_rules/estimate_overrun.rb +37 -0
- data/lib/snitcher/anomaly_rules/excessive_meeting_time.rb +43 -0
- data/lib/snitcher/anomaly_rules/inactive_working_days.rb +32 -0
- data/lib/snitcher/anomaly_rules/time_above_availability.rb +27 -0
- data/lib/snitcher/client.rb +177 -0
- data/lib/snitcher/configuration.rb +123 -0
- data/lib/snitcher/holiday_region_resolver.rb +123 -0
- data/lib/snitcher/jql_validator.rb +44 -0
- data/lib/snitcher/meetings_aggregator.rb +38 -0
- data/lib/snitcher/orchestrator.rb +227 -0
- data/lib/snitcher/presenters/html.rb +87 -0
- data/lib/snitcher/presenters/markdown.rb +140 -0
- data/lib/snitcher/presenters/presenter.rb +90 -0
- data/lib/snitcher/report.rb +112 -0
- data/lib/snitcher/report_service.rb +128 -0
- data/lib/snitcher/report_writer.rb +74 -0
- data/lib/snitcher/sources/clickup/archived_lists.rb +174 -0
- data/lib/snitcher/sources/clickup/working_hours_filter.rb +88 -0
- data/lib/snitcher/sources/clickup.rb +467 -0
- data/lib/snitcher/sources/git/command_runner.rb +198 -0
- data/lib/snitcher/sources/git.rb +347 -0
- data/lib/snitcher/sources/google_calendar.rb +328 -0
- data/lib/snitcher/sources/jira/client.rb +346 -0
- data/lib/snitcher/sources/jira/status_categories.rb +60 -0
- data/lib/snitcher/sources/jira.rb +523 -0
- data/lib/snitcher/sources/linear.rb +257 -0
- data/lib/snitcher/sources/source.rb +140 -0
- data/lib/snitcher/templates/report.html.erb +564 -0
- data/lib/snitcher/templates/snitcher-logo.png +0 -0
- data/lib/snitcher/time_formatter.rb +27 -0
- data/lib/snitcher/validators.rb +59 -0
- data/lib/snitcher/version.rb +5 -0
- data/lib/snitcher/work_item.rb +75 -0
- data/lib/snitcher/working_hours_calculator.rb +205 -0
- data/lib/snitcher/working_hours_config.rb +208 -0
- data/lib/snitcher.rb +39 -0
- 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
|