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,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Snitcher
6
+ # An immutable, deterministic signal flagged by an anomaly rule (a fact, not an opinion).
7
+ # Rendered as a report section and exposed in the fact payload via #to_h.
8
+ Anomaly = Data.define(:code, :severity, :title, :description, :data) do
9
+ def severity_rank
10
+ self.class::SEVERITIES.index(severity) || -1
11
+ end
12
+
13
+ def to_h
14
+ { code: code, severity: severity, title: title, description: description, data: data }
15
+ end
16
+
17
+ def to_json(...)
18
+ to_h.to_json(...)
19
+ end
20
+ end
21
+
22
+ # Ordered least → most severe; index gives the rank used for sorting.
23
+ Anomaly::SEVERITIES = %i[info warning critical].freeze
24
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Snitcher
4
+ # Read-only view over a report's items + period + working-hours config, exposing the
5
+ # memoized aggregates rules need. Keeps rules small and consistent.
6
+ class AnomalyContext
7
+ TICKET_SOURCES = %i[jira clickup linear].freeze
8
+
9
+ attr_reader :items, :since, :until_time, :working_hours
10
+
11
+ def initialize(items:, since: nil, until_time: nil, working_hours: nil)
12
+ @items = items
13
+ @since = since
14
+ @until_time = until_time
15
+ @working_hours = working_hours
16
+ end
17
+
18
+ def non_meeting_items = @non_meeting_items ||= MeetingsAggregator.non_meeting_items(items)
19
+ def commit_items = @commit_items ||= items.select { |i| i.source == :git }
20
+ def ticket_items = @ticket_items ||= items.select { |i| TICKET_SOURCES.include?(i.source) }
21
+ def total_tracked_seconds = @total_tracked_seconds ||= non_meeting_items.sum { |i| i.time_spent_seconds || 0 }
22
+ def meeting_seconds = @meeting_seconds ||= MeetingsAggregator.total_seconds(items)
23
+
24
+ def period?
25
+ !since.nil? && !until_time.nil? && until_time > since
26
+ end
27
+
28
+ # Available working seconds in the period; nil when capacity can't be computed.
29
+ def available_seconds
30
+ return nil unless working_hours && since && until_time
31
+ return @available_seconds if defined?(@available_seconds)
32
+
33
+ @available_seconds = WorkingHoursCalculator.seconds_between(since, until_time, working_hours)
34
+ end
35
+
36
+ # Dates (Date) with at least one completed non-meeting item.
37
+ def active_dates
38
+ @active_dates ||= non_meeting_items.filter_map { |i| i.completed_at&.to_date }.to_set
39
+ end
40
+
41
+ # Working dates expected in the period (work_days minus holidays when configured).
42
+ def expected_working_dates
43
+ return [] unless period?
44
+
45
+ @expected_working_dates ||= begin
46
+ last_inclusive = (until_time - TimeFormatter::SECONDS_PER_DAY).to_date
47
+ (since.to_date..last_inclusive).select do |date|
48
+ work_days.include?(date.wday) && !holiday_dates.include?(date)
49
+ end
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def work_days
56
+ @work_days ||= (working_hours && working_hours[:work_days]) ||
57
+ WorkingHoursCalculator::DEFAULT_WORK_DAYS
58
+ end
59
+
60
+ def holiday_dates
61
+ @holiday_dates ||= begin
62
+ region = working_hours && working_hours[:holidays_region]
63
+ if region && period?
64
+ years = (since.year..(until_time - TimeFormatter::SECONDS_PER_DAY).year)
65
+ years.flat_map { |y| HolidayRegionResolver.holidays_for_year(region.to_sym, y) }
66
+ .to_set { |h| h[:date] }
67
+ else
68
+ Set.new
69
+ end
70
+ rescue StandardError => e
71
+ warn "[anomaly] Failed to load holidays for region #{region.inspect}: #{e.message}" if ENV['DEBUG']
72
+ Set.new
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Snitcher
4
+ # Runs registered deterministic rules over a report's data. Each rule is isolated: a rule that
5
+ # raises is logged and skipped (mirrors Orchestrator#fetch) so one bad rule never breaks a report.
6
+ class AnomalyDetector
7
+ RULES = [
8
+ AnomalyRules::CommitsWithoutClosedTickets,
9
+ AnomalyRules::TimeAboveAvailability,
10
+ AnomalyRules::InactiveWorkingDays,
11
+ AnomalyRules::EstimateOverrun,
12
+ AnomalyRules::ExcessiveMeetingTime
13
+ ].freeze
14
+
15
+ def initialize(items, since: nil, until_time: nil, working_hours: nil, rules: RULES)
16
+ @context = AnomalyContext.new(items: items, since: since, until_time: until_time,
17
+ working_hours: working_hours)
18
+ @rules = rules
19
+ end
20
+
21
+ # @return [Array<Anomaly>] most-severe first
22
+ def detect
23
+ @rules.flat_map do |rule|
24
+ rule.call(@context)
25
+ rescue StandardError => e
26
+ warn "[anomaly:#{rule}] Error: #{e.class}: #{e.message}"
27
+ warn " Backtrace: #{e.backtrace.first(5).join("\n ")}" if ENV['DEBUG']
28
+ []
29
+ end.sort_by { |a| -a.severity_rank }
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Snitcher
4
+ # Deterministic anomaly rules. Each inspects an AnomalyContext and returns zero or more Anomaly.
5
+ # Add a rule: subclass Base, implement #call, register in AnomalyDetector::RULES.
6
+ module AnomalyRules
7
+ class Base
8
+ def self.call(context) = new(context).call
9
+
10
+ def initialize(context)
11
+ @context = context
12
+ end
13
+
14
+ def call
15
+ raise NotImplementedError, "#{self.class} must implement #call"
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :context
21
+
22
+ def anomaly(code:, severity:, title:, description:, data: {})
23
+ Anomaly.new(code: code, severity: severity, title: title, description: description, data: data)
24
+ end
25
+
26
+ def format_duration(seconds) = TimeFormatter.format_duration(seconds)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Snitcher
4
+ module AnomalyRules
5
+ # Meaningful commit activity not reflected by any closed ticket (untracked work / tickets
6
+ # never moved to done).
7
+ class CommitsWithoutClosedTickets < Base
8
+ MIN_COMMITS = 3
9
+
10
+ def call
11
+ commits = context.commit_items.size
12
+ tickets = context.ticket_items.size
13
+ return [] unless commits >= MIN_COMMITS && tickets.zero?
14
+
15
+ [anomaly(code: :commits_without_closed_tickets, severity: :warning,
16
+ title: 'Commits without closed tickets',
17
+ description: "#{commits} commits recorded but no tickets were closed in this period.",
18
+ data: { commits: commits, closed_tickets: tickets })]
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Snitcher
4
+ module AnomalyRules
5
+ # Tickets whose logged/calculated time greatly exceeds their estimate. Only Linear exposes an
6
+ # estimate, in story points, so points are converted to an expected duration via a heuristic.
7
+ class EstimateOverrun < Base
8
+ SECONDS_PER_POINT = 4 * 3_600 # ~4h per story point
9
+ OVERRUN_RATIO = 1.5
10
+
11
+ def call
12
+ results = context.ticket_items.filter_map do |item|
13
+ m = item.metadata
14
+ estimate = m && (m[:estimate] || m['estimate'])
15
+ actual = item.time_spent_seconds
16
+ next unless estimate&.positive? && actual&.positive?
17
+
18
+ expected = estimate * SECONDS_PER_POINT
19
+ next unless actual > expected * OVERRUN_RATIO
20
+
21
+ anomaly(code: :estimate_overrun, severity: :warning,
22
+ title: 'Time well above estimate',
23
+ description: "#{item.external_id} took #{format_duration(actual)} " \
24
+ "vs ~#{format_duration(expected)} expected for #{estimate} point(s).",
25
+ data: { external_id: item.external_id, estimate_points: estimate,
26
+ time_spent_seconds: actual, expected_seconds: expected })
27
+ end
28
+
29
+ if ENV.fetch('DEBUG', nil) && results.empty? && context.ticket_items.any?
30
+ warn '[anomaly:estimate_overrun] Skipped — no items with estimate data (Linear-only rule)'
31
+ end
32
+
33
+ results
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Snitcher
4
+ module AnomalyRules
5
+ # High meeting load — meetings taking up more than half of the available working time in the
6
+ # period. Expressed as a share of working time (not an absolute weekly figure) so it scales
7
+ # naturally to how many days a person works: 50% of a 5-day week, 50% of a 4-day week, etc.
8
+ # May itself explain other signals (e.g. low ticket throughput), so it's flagged as a warning.
9
+ # Skipped without a usable period.
10
+ class ExcessiveMeetingTime < Base
11
+ MEETING_LOAD_RATIO = 0.5 # flag when meetings exceed 50% of available working time
12
+ DEFAULT_HOURS_PER_DAY = 8 # assumed working day length when working_hours isn't configured
13
+
14
+ def call
15
+ return [] unless context.period?
16
+
17
+ available = available_working_seconds
18
+ return [] if available.nil? || available <= 0
19
+
20
+ meeting_seconds = context.meeting_seconds
21
+ return [] if meeting_seconds <= available * MEETING_LOAD_RATIO
22
+
23
+ ratio = meeting_seconds.to_f / available
24
+ [anomaly(code: :excessive_meeting_time, severity: :warning,
25
+ title: 'High meeting load',
26
+ description: "Meetings take up #{(ratio * 100).round}% of working time " \
27
+ "(#{format_duration(meeting_seconds)} of #{format_duration(available)} " \
28
+ 'available); may also explain other signals.',
29
+ data: { meeting_seconds: meeting_seconds, available_seconds: available,
30
+ ratio: ratio.round(2) })]
31
+ end
32
+
33
+ private
34
+
35
+ # Working capacity for the period. Uses the precise working-hours calculation when configured,
36
+ # else falls back to expected working days (Mon–Fri by default) at 8h each.
37
+ def available_working_seconds
38
+ context.available_seconds ||
39
+ (context.expected_working_dates.size * DEFAULT_HOURS_PER_DAY * TimeFormatter::SECONDS_PER_HOUR)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Snitcher
4
+ module AnomalyRules
5
+ # Working days within the period with no recorded activity. work_days/holidays from
6
+ # working_hours when present, else Monday–Friday. Skipped without a usable period.
7
+ class InactiveWorkingDays < Base
8
+ MAX_LISTED = 5
9
+
10
+ def call
11
+ return [] unless context.period?
12
+
13
+ active = context.active_dates
14
+ inactive = context.expected_working_dates.reject { |d| active.include?(d) }
15
+ return [] if inactive.empty?
16
+
17
+ [anomaly(code: :inactive_working_days, severity: :info,
18
+ title: 'Working days without activity',
19
+ description: build_description(inactive),
20
+ data: { count: inactive.size, dates: inactive.map(&:iso8601) })]
21
+ end
22
+
23
+ private
24
+
25
+ def build_description(inactive)
26
+ listed = inactive.first(MAX_LISTED).map(&:iso8601).join(', ')
27
+ suffix = inactive.size > MAX_LISTED ? ", +#{inactive.size - MAX_LISTED} more" : ''
28
+ "#{inactive.size} working day(s) with no recorded activity: #{listed}#{suffix}."
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Snitcher
4
+ module AnomalyRules
5
+ # Logged time significantly above working-hours availability. Skipped without working_hours.
6
+ class TimeAboveAvailability < Base
7
+ WARNING_RATIO = 1.10
8
+ CRITICAL_RATIO = 1.50
9
+
10
+ def call
11
+ available = context.available_seconds
12
+ return [] if available.nil? || available <= 0
13
+
14
+ tracked = context.total_tracked_seconds
15
+ ratio = tracked.to_f / available
16
+ return [] if ratio <= WARNING_RATIO
17
+
18
+ [anomaly(code: :time_above_availability,
19
+ severity: ratio > CRITICAL_RATIO ? :critical : :warning,
20
+ title: 'Logged time above availability',
21
+ description: "Logged #{format_duration(tracked)} vs ~#{format_duration(available)} " \
22
+ "available for this period (#{(ratio * 100).round}%).",
23
+ data: { tracked_seconds: tracked, available_seconds: available, ratio: ratio.round(2) })]
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Snitcher
4
+ # Embeddable facade for host applications. Build one Client from a config file and
5
+ # org-level ENV credentials, then call #work_report per user or #work_reports to fan
6
+ # out over several users in one call. Wraps ReportService + Orchestrator, and renders
7
+ # any format Report supports — a host never has to assemble an Orchestrator itself.
8
+ #
9
+ # @example
10
+ # snitcher = Snitcher.new(config_path: "config/snitcher.yml")
11
+ # snitcher.work_report(user_email: "alice@corp.com", from: "2026-07-01", to: "2026-07-14")
12
+ # # => { metadata:, anomalies:, items:, summary: }
13
+ # snitcher.work_report(user_email: "alice@corp.com", from: "2026-07-01", format: :html)
14
+ # # => { metadata: { report_path: ".../alice-at-corp.com_2026-07-01_2026-08-27.html", ... }, ... }
15
+ # snitcher.work_reports(user_emails: %w[alice@corp.com bob@corp.com], from: "2026-07-01")
16
+ # # => { "alice@corp.com" => { metadata:, anomalies:, items:, summary: }, "bob@corp.com" => { ... } }
17
+ class Client
18
+ # Sources that serve multi-user reports with any configuration. google_calendar is
19
+ # absent because it needs a service account. Ask .multi_user_sources for the effective
20
+ # list, which depends on the credentials that are configured right now.
21
+ MULTI_USER_SOURCES = %i[git jira clickup linear].freeze
22
+
23
+ # Kept in step with #warn_single_user_sources: the operator reads one on stderr, the host
24
+ # reads the other in metadata[:failed_sources], and the two must not disagree. Names the
25
+ # setting to change, per the contract in Sources::Source#unavailable_reason.
26
+ SINGLE_USER_ERROR = 'cannot serve a multi-user report with the configured credentials; set ' \
27
+ "#{Sources::GoogleCalendar::SERVICE_ACCOUNT_ENV} to report on several " \
28
+ 'users from Google Calendar'.freeze
29
+
30
+ # The sources to build an orchestrator from, paired with the ones dropped on the way.
31
+ # A Data object rather than a two-element Array: both values are Arrays and would be
32
+ # silently swappable at the call site.
33
+ ResolvedSources = Data.define(:requested, :unavailable)
34
+
35
+ # A source is multi-user when one set of organization credentials can serve any
36
+ # requested user. Sources may opt out at runtime — google_calendar does when only
37
+ # OAuth is configured, since one refresh token cannot honour a per-user request.
38
+ # Sources that do not answer the predicate are multi-user unconditionally.
39
+ def self.multi_user_source?(source_name)
40
+ source_class = Orchestrator::SOURCE_MAP[source_name]
41
+ return false unless source_class
42
+ return true unless source_class.respond_to?(:multi_user_capable?)
43
+
44
+ source_class.multi_user_capable?
45
+ end
46
+
47
+ # The sources that can serve a multi-user report with the credentials configured now.
48
+ def self.multi_user_sources
49
+ Orchestrator::SOURCE_MAP.keys.select { |name| multi_user_source?(name) }
50
+ end
51
+
52
+ def initialize(config_path: Configuration::DEFAULT_CONFIG_FILE)
53
+ @configuration = Configuration.new(config_path)
54
+ end
55
+
56
+ # Generates a work report for one user. user_email may be omitted to fall back to the
57
+ # per-source default identity (sources.<name>.user) from the config file.
58
+ # @param format [Symbol, String] one of Report::FORMATS. :markdown and :html are
59
+ # written to disk; :hash (the default) returns the payload.
60
+ # @param narrative [String, nil] prose summary rendered as an Overview section by the
61
+ # markdown and html formats. The gem generates none — a host that has a model
62
+ # available passes its own text in.
63
+ # @param output_dir [String, nil] directory for a saved document, overriding
64
+ # `output_dir` from the config file.
65
+ # @param output_path [String, nil] exact file path for a saved document, overriding
66
+ # both output_dir and the generated filename.
67
+ # @return [Hash] { metadata:, anomalies:, items:, summary: } — the same shape for every
68
+ # format. A saved format sets metadata[:report_path] and metadata[:report_format].
69
+ # rubocop:disable-next Metrics/ParameterLists -- keyword args are self-documenting
70
+ def work_report(from:, user_email: nil, to: nil, sources: nil, include_anomalies: true,
71
+ format: :hash, narrative: nil, output_dir: nil, output_path: nil)
72
+ fmt = ReportService.normalize_format(format)
73
+ resolved = resolve_sources(sources)
74
+ orchestrator = Orchestrator.new(sources: resolved.requested, configuration: @configuration,
75
+ unavailable: resolved.unavailable)
76
+ ReportService.call(
77
+ user_email: user_email, from: from, to: to,
78
+ include_anomalies: include_anomalies, format: fmt, narrative: narrative,
79
+ output_dir: output_dir, output_path: output_path,
80
+ orchestrator: orchestrator, configuration: @configuration
81
+ )
82
+ end
83
+
84
+ # Generates work reports for several users in one call, reusing a single
85
+ # Orchestrator. Returns a Hash keyed by the requested email; a user whose
86
+ # report fails (e.g. an invalid email or a source error that escapes the
87
+ # Orchestrator) gets an { error:, error_class: } entry instead of aborting
88
+ # the batch. Batch-wide input errors (empty user_emails, unknown source,
89
+ # unknown format) raise before any report runs.
90
+ # @param user_emails [Array<String>] one or more work emails
91
+ # @param format [Symbol, String] as in #work_report; applies to every user. A saved
92
+ # format writes one document per user, each named after that user.
93
+ # @param narrative [Hash{String => String}, String, nil] per-user prose keyed by the
94
+ # requested email, or one String applied to every report
95
+ # @param output_dir [String, nil] directory for the saved documents. There is no
96
+ # output_path counterpart: one path for a batch would have every user overwrite
97
+ # the same file.
98
+ # @return [Hash{String => Hash}] { email => report | { error:, error_class: } }.
99
+ # Every value is a Hash: the payload for a user whose report ran (carrying
100
+ # metadata[:report_path] for a saved format), or { error:, error_class: } for one
101
+ # that failed — so a caller tells the two apart by looking for :error, never by
102
+ # checking the value's type.
103
+ # rubocop:disable-next Metrics/ParameterLists -- keyword args are self-documenting
104
+ def work_reports(user_emails:, from:, to: nil, sources: nil, include_anomalies: true,
105
+ format: :hash, narrative: nil, output_dir: nil)
106
+ emails = Array(user_emails).map { |e| e.to_s.strip }.reject(&:empty?)
107
+ raise ArgumentError, 'user_emails must contain at least one email' if emails.empty?
108
+
109
+ fmt = ReportService.normalize_format(format)
110
+ resolved = resolve_sources(sources)
111
+ orchestrator = Orchestrator.new(sources: resolved.requested, configuration: @configuration,
112
+ unavailable: resolved.unavailable)
113
+
114
+ emails.to_h do |email|
115
+ payload = begin
116
+ ReportService.call(
117
+ user_email: email, from: from, to: to,
118
+ include_anomalies: include_anomalies, format: fmt,
119
+ narrative: narrative_for(narrative, email), output_dir: output_dir,
120
+ orchestrator: orchestrator, configuration: @configuration
121
+ )
122
+ rescue StandardError => e
123
+ { error: e.message, error_class: e.class.name }
124
+ end
125
+ [email, payload]
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ # One person's narrative out of the batch argument. A Hash is looked up by the
132
+ # requested email (a missing entry simply means no Overview for that user); a plain
133
+ # String is text that applies to everyone.
134
+ def narrative_for(narrative, email)
135
+ narrative.is_a?(Hash) ? (narrative[email] || narrative[email.to_sym]) : narrative
136
+ end
137
+
138
+ # Resolves the requested sources to a ResolvedSources pairing an explicit, validated
139
+ # symbol list with the ones dropped along the way (warning when a source is dropped).
140
+ # nil / "" / "all" expand to every known source, then go through the same multi-user
141
+ # rejection as an explicit list — so a source that cannot serve a multi-user report
142
+ # with the credentials configured now is recorded as unavailable there too, never
143
+ # dropped without a trace. requested is [] only when the host explicitly requested
144
+ # exclusively unsupported sources — an empty Orchestrator, i.e. an empty report.
145
+ def resolve_sources(sources)
146
+ keys = Orchestrator::SOURCE_MAP.keys
147
+ list = Array(sources).flat_map { |s| s.to_s.split(',') }.map(&:strip).reject(&:empty?)
148
+
149
+ requested =
150
+ if list.empty? || list.include?('all')
151
+ keys
152
+ else
153
+ syms = list.map(&:to_sym)
154
+ invalid = syms - keys
155
+ if invalid.any?
156
+ raise ArgumentError,
157
+ "Unknown source(s): #{invalid.join(', ')}. Available: #{keys.join(', ')}"
158
+ end
159
+ syms
160
+ end
161
+
162
+ rejected = requested.reject { |name| self.class.multi_user_source?(name) }
163
+ warn_single_user_sources(rejected) if rejected.any?
164
+ ResolvedSources.new(
165
+ requested: requested - rejected,
166
+ unavailable: rejected.map { |name| { source: name, error: SINGLE_USER_ERROR } }
167
+ )
168
+ end
169
+
170
+ def warn_single_user_sources(rejected)
171
+ warn "[Snitcher] #{rejected.join(', ')} cannot serve multi-user reports with the " \
172
+ 'configured credentials and is skipped. Set ' \
173
+ "#{Sources::GoogleCalendar::SERVICE_ACCOUNT_ENV} to report on several users " \
174
+ 'from Google Calendar.'
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Snitcher
6
+ # Manages per-project configuration stored in .snitcher.yml.
7
+ #
8
+ # The configuration file stores status mappings that allow the app to work with
9
+ # non-English workspaces (Polish, German, etc.).
10
+ #
11
+ # @example File format
12
+ # version: 1
13
+ # sources:
14
+ # clickup:
15
+ # done_statuses: ["Zamkniete", "Done"]
16
+ # in_progress_statuses: ["W trakcie"]
17
+ # jira:
18
+ # done_statuses: ["Zrobione"]
19
+ #
20
+ # @example Usage
21
+ # config = Configuration.new
22
+ # config.statuses_for(:clickup, :done) # => ["Zamkniete", "Done"]
23
+ #
24
+ class Configuration
25
+ DEFAULT_CONFIG_FILE = '.snitcher.yml'
26
+
27
+ attr_reader :config_path
28
+
29
+ def initialize(config_path = DEFAULT_CONFIG_FILE)
30
+ @config_path = config_path
31
+ @data = load_config
32
+ end
33
+
34
+ def exists?
35
+ File.exist?(@config_path)
36
+ end
37
+
38
+ def version
39
+ @data['version'] || 1
40
+ end
41
+
42
+ # Returns source-specific config hash, e.g. configuration.source(:clickup)
43
+ # @param source_name [Symbol] :jira, :clickup, :linear
44
+ # @return [Hash] source configuration or empty hash
45
+ def source(source_name)
46
+ (@data.dig('sources', source_name.to_s) || {}).transform_keys(&:to_sym)
47
+ end
48
+
49
+ # Returns array of status names for a given source and category
50
+ # @param source_name [Symbol] :jira, :clickup, :linear
51
+ # @param category [Symbol] :done, :in_progress, :todo
52
+ # @return [Array<String>] status names or empty array
53
+ def statuses_for(source_name, category)
54
+ key = "#{category}_statuses"
55
+ source(source_name)[key.to_sym] || []
56
+ end
57
+
58
+ # Returns the work-email => git-email(s) identity map from sources.git.emails.
59
+ # Values are normalized to arrays, since one person may commit under several
60
+ # addresses (work laptop, personal, GitHub noreply).
61
+ # @return [Hash{String=>Array<String>}] empty hash when unconfigured
62
+ def git_emails
63
+ (source(:git)[:emails] || {}).to_h { |work, git| [work.to_s, Array(git).map(&:to_s)] }
64
+ end
65
+
66
+ # Directory that saved markdown/html reports are written to (top-level `output_dir`).
67
+ # @return [String, nil] the configured directory, or nil to use ReportWriter's default
68
+ def output_dir
69
+ dir = @data['output_dir']
70
+ return nil if dir.nil?
71
+
72
+ dir = dir.to_s.strip
73
+ dir.empty? ? nil : dir
74
+ end
75
+
76
+ # Returns working hours configuration (top-level, not source-specific).
77
+ # Validates timezone and field formats lazily on first access.
78
+ # @param user_email [String, nil] resolve one person's hours (working_hours.people);
79
+ # nil returns the shared block that applies to everyone else
80
+ # @return [Hash, nil] working hours config with symbolized keys, or nil if not configured
81
+ def working_hours(user_email: nil)
82
+ working_hours_config.for(user_email)
83
+ end
84
+
85
+ # The hours configured for one person specifically, nil when they are not named under
86
+ # working_hours.people. Lets Orchestrator tell "this person is different" from "everyone
87
+ # gets the shared block", so it never clobbers a working_hours handed straight to
88
+ # Orchestrator.new(config:).
89
+ # @param user_email [String, nil]
90
+ # @return [Hash, nil]
91
+ def working_hours_override(user_email)
92
+ working_hours_config.override_for(user_email)
93
+ end
94
+
95
+ # Returns source config with global working_hours merged in.
96
+ # @param source_name [Symbol] :jira, :clickup, :linear
97
+ # @return [Hash] source config, potentially including :working_hours key
98
+ def source_with_working_hours(source_name)
99
+ src = source(source_name)
100
+ wh = working_hours
101
+ src[:working_hours] = wh if wh
102
+ src
103
+ end
104
+
105
+ private
106
+
107
+ def working_hours_config
108
+ @working_hours_config ||= WorkingHoursConfig.new(@data['working_hours'])
109
+ end
110
+
111
+ def load_config
112
+ # No error if file missing — backward compatible behavior (existing behavior preserved)
113
+ return {} unless File.exist?(@config_path)
114
+
115
+ # Use safe_load without Symbol permission to prevent potential DoS attacks.
116
+ # Keys are transformed to symbols in #source method after loading.
117
+ YAML.safe_load_file(@config_path, permitted_classes: []) || {}
118
+ rescue Psych::SyntaxError => e
119
+ warn "[Snitcher] Warning: Invalid .snitcher.yml syntax: #{e.message}"
120
+ {}
121
+ end
122
+ end
123
+ end