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,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
|