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,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tzinfo'
|
|
4
|
+
require 'holidays'
|
|
5
|
+
|
|
6
|
+
module Snitcher
|
|
7
|
+
# Maps IANA timezone identifiers to holidays gem region symbols.
|
|
8
|
+
# Uses TZInfo::Country to build a reverse lookup (timezone → country code),
|
|
9
|
+
# then validates the country code is available in the holidays gem.
|
|
10
|
+
#
|
|
11
|
+
# Some countries only have sub-regions in the holidays gem (e.g. Belgium → :be_fr).
|
|
12
|
+
# These are handled via a static fallback map.
|
|
13
|
+
module HolidayRegionResolver
|
|
14
|
+
# Countries where the base code isn't in holidays gem but a sub-region is.
|
|
15
|
+
SUB_REGION_FALLBACK = {
|
|
16
|
+
'be' => 'be_fr',
|
|
17
|
+
'bg' => 'bg_en',
|
|
18
|
+
'mt' => 'mt_en',
|
|
19
|
+
'rs' => 'rs_la'
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
# Timezones shared by multiple countries in TZInfo where the automatic
|
|
23
|
+
# "fewest zones" heuristic picks the wrong country. Keyed by IANA
|
|
24
|
+
# timezone identifier, valued with the correct ISO 3166-1 alpha-2 code.
|
|
25
|
+
TIMEZONE_COUNTRY_OVERRIDES = {
|
|
26
|
+
'Europe/Belgrade' => 'rs', # Belgrade is capital of Serbia
|
|
27
|
+
'Europe/Berlin' => 'de', # Berlin is capital of Germany
|
|
28
|
+
'America/Phoenix' => 'us' # Phoenix is in Arizona, USA
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# Resolve IANA timezone to holidays gem region symbol.
|
|
33
|
+
# @param timezone_name [String] IANA timezone (e.g. "Europe/Warsaw")
|
|
34
|
+
# @return [Symbol, nil] holidays gem region (e.g. :pl), or nil if unmappable
|
|
35
|
+
def resolve(timezone_name)
|
|
36
|
+
country_code = timezone_to_country(timezone_name)
|
|
37
|
+
return nil unless country_code
|
|
38
|
+
|
|
39
|
+
region = SUB_REGION_FALLBACK.fetch(country_code, country_code)
|
|
40
|
+
region_sym = region.to_sym
|
|
41
|
+
|
|
42
|
+
valid_region?(region_sym) ? region_sym : nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Checks if a region string/symbol is supported by the holidays gem.
|
|
46
|
+
# @param region [String, Symbol] region to validate
|
|
47
|
+
# @return [Boolean]
|
|
48
|
+
def valid_region?(region)
|
|
49
|
+
available_regions_set.include?(region.to_sym)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Returns public holidays for a given region and year.
|
|
53
|
+
# Uses :observed flag to include substitute days (consistent with WorkingHoursCalculator).
|
|
54
|
+
# @param region [Symbol] holidays gem region (e.g. :pl)
|
|
55
|
+
# @param year [Integer] calendar year
|
|
56
|
+
# @return [Array<Hash>] holiday entries with :date and :name keys
|
|
57
|
+
def holidays_for_year(region, year)
|
|
58
|
+
Holidays.between(Date.new(year, 1, 1), Date.new(year, 12, 31), region, :observed)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Returns the country name for display during init prompts.
|
|
62
|
+
# @param timezone_name [String] IANA timezone
|
|
63
|
+
# @return [String, nil] country name (e.g. "Poland"), or nil
|
|
64
|
+
def country_name(timezone_name)
|
|
65
|
+
country_code = timezone_to_country(timezone_name)
|
|
66
|
+
return nil unless country_code
|
|
67
|
+
|
|
68
|
+
TZInfo::Country.get(country_code.upcase).name
|
|
69
|
+
rescue TZInfo::InvalidCountryCode
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Resets cached timezone→country map and region set. Call in test setup to prevent leakage.
|
|
74
|
+
def reset_cache!
|
|
75
|
+
@tz_country_map = nil
|
|
76
|
+
@available_regions_set = nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# @return [String, nil] lowercase ISO 3166-1 alpha-2 code
|
|
82
|
+
def timezone_to_country(timezone_name)
|
|
83
|
+
tz_country_map[timezone_name]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Lazily builds and caches timezone → country code map.
|
|
87
|
+
# Thread-safe for reads after first build (CLI is single-threaded).
|
|
88
|
+
def tz_country_map
|
|
89
|
+
@tz_country_map ||= build_tz_country_map
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @return [Set<Symbol>] cached set of available holidays gem regions
|
|
93
|
+
def available_regions_set
|
|
94
|
+
@available_regions_set ||= Holidays.available_regions.to_set
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Builds a hash mapping every IANA timezone identifier to a single lowercase
|
|
98
|
+
# ISO 3166-1 alpha-2 country code, resolving multi-country timezones via
|
|
99
|
+
# TIMEZONE_COUNTRY_OVERRIDES or fewest-zones heuristic.
|
|
100
|
+
# @return [Hash{String => String}] timezone_name → country_code
|
|
101
|
+
def build_tz_country_map
|
|
102
|
+
# Collect all countries claiming each timezone
|
|
103
|
+
tz_candidates = Hash.new { |h, k| h[k] = [] }
|
|
104
|
+
TZInfo::Country.all.each do |country|
|
|
105
|
+
zones = country.zone_identifiers
|
|
106
|
+
code = country.code.downcase
|
|
107
|
+
zones.each { |tz| tz_candidates[tz] << [code, zones.size] }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
tz_candidates.each_with_object({}) do |(tz, candidates), map|
|
|
111
|
+
map[tz] = if TIMEZONE_COUNTRY_OVERRIDES.key?(tz)
|
|
112
|
+
TIMEZONE_COUNTRY_OVERRIDES[tz]
|
|
113
|
+
elsif candidates.size == 1
|
|
114
|
+
candidates.first[0]
|
|
115
|
+
else
|
|
116
|
+
# Prefer country with fewest zones (most specific mapping)
|
|
117
|
+
candidates.min_by { |_code, zone_count| zone_count }[0]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Snitcher
|
|
4
|
+
# JQL (Jira Query Language) validation and escaping.
|
|
5
|
+
#
|
|
6
|
+
# SECURITY NOTE: The JIRA REST API does not support parameterized JQL queries.
|
|
7
|
+
# All JQL must be constructed as strings. This module provides:
|
|
8
|
+
# 1. Input validation to reject suspicious patterns
|
|
9
|
+
# 2. Explicit escaping of user-controlled values
|
|
10
|
+
# 3. Documentation of security assumptions
|
|
11
|
+
module JQLValidator
|
|
12
|
+
# Characters that are dangerous in JQL queries and should be blocked
|
|
13
|
+
# These are JQL operators and special syntax that could modify query semantics
|
|
14
|
+
JQL_DANGEROUS_CHARS = /[{}()\[\]"\\*:|&!]/
|
|
15
|
+
|
|
16
|
+
# Validate that email is safe for use in JQL queries
|
|
17
|
+
# IMPORTANT: This is a defense-in-depth check AFTER email format validation.
|
|
18
|
+
# The email has already been validated by Validators.validate_email!, which
|
|
19
|
+
# blocks these characters, but we validate again here as explicit defense.
|
|
20
|
+
def self.validate_email_for_jql!(email)
|
|
21
|
+
if email.match?(JQL_DANGEROUS_CHARS)
|
|
22
|
+
raise Validators::ValidationError, "Email contains unsafe JQL characters: #{email}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
email
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Escape user values for safe inclusion in JQL strings
|
|
29
|
+
# JQL uses double quotes for string literals, so we escape quotes inside values
|
|
30
|
+
def self.escape_jql_string(value)
|
|
31
|
+
value.gsub('"', '\\"')
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Validate date string format (YYYY-MM-DD)
|
|
35
|
+
# Dates are constructed by our code, not user input, but validate for safety
|
|
36
|
+
def self.validate_date_format!(date_string)
|
|
37
|
+
unless date_string.match?(/^\d{4}-\d{2}-\d{2}$/)
|
|
38
|
+
raise Validators::ValidationError, "Invalid date format: #{date_string}. Expected YYYY-MM-DD"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
date_string
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Snitcher
|
|
4
|
+
# Collapses google_calendar WorkItems into per-series rows for the Meetings report section.
|
|
5
|
+
module MeetingsAggregator
|
|
6
|
+
MEETING_SOURCE = :google_calendar
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def meeting_items(items) = items.select { |i| i.source == MEETING_SOURCE }
|
|
11
|
+
def non_meeting_items(items) = items.reject { |i| i.source == MEETING_SOURCE }
|
|
12
|
+
def total_seconds(items) = meeting_items(items).sum { |i| i.time_spent_seconds || 0 }
|
|
13
|
+
def any?(items) = meeting_items(items).any?
|
|
14
|
+
|
|
15
|
+
# @return [Hash] { rows: [{title:, count:, total_seconds:}], event_count:, total_seconds: }
|
|
16
|
+
# rows sorted by total_seconds desc; recurring instances collapsed by series id.
|
|
17
|
+
def aggregate(items)
|
|
18
|
+
meetings = meeting_items(items)
|
|
19
|
+
rows = meetings.group_by { |i| series_key(i) }.map do |_key, group|
|
|
20
|
+
{ title: group.first.title,
|
|
21
|
+
count: group.size,
|
|
22
|
+
total_seconds: group.sum { |i| i.time_spent_seconds || 0 } }
|
|
23
|
+
end.sort_by { |r| -r[:total_seconds] }
|
|
24
|
+
|
|
25
|
+
{ rows: rows, event_count: meetings.size,
|
|
26
|
+
total_seconds: meetings.sum { |i| i.time_spent_seconds || 0 } }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Items round-tripped through NDJSON/JSON have string metadata keys (see work_item.rb#from_h),
|
|
30
|
+
# so tolerate both symbol and string keys for the series id.
|
|
31
|
+
def series_key(item)
|
|
32
|
+
md = item.metadata || {}
|
|
33
|
+
# Non-recurring events have no series id, so they fall back to title: two distinct one-off
|
|
34
|
+
# meetings with the same title intentionally collapse into one row (acceptable trade-off).
|
|
35
|
+
md[:recurring_event_id] || md['recurring_event_id'] || item.title
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Snitcher
|
|
4
|
+
class Orchestrator
|
|
5
|
+
SOURCE_MAP = {
|
|
6
|
+
jira: Sources::Jira,
|
|
7
|
+
git: Sources::Git,
|
|
8
|
+
linear: Sources::Linear,
|
|
9
|
+
clickup: Sources::ClickUp,
|
|
10
|
+
google_calendar: Sources::GoogleCalendar
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
# Sources that failed to run during the last #fetch, as [{ source:, error: }] — not a
|
|
14
|
+
# source that ran and simply had nothing to report, which is a legitimate answer and
|
|
15
|
+
# stays unlisted. Two kinds of failure land here: a source that raised while fetching,
|
|
16
|
+
# and one that never started because it was not available when this orchestrator was
|
|
17
|
+
# built.
|
|
18
|
+
attr_reader :failed_sources
|
|
19
|
+
|
|
20
|
+
def initialize(sources: SOURCE_MAP.keys, config: {}, configuration: Configuration.new,
|
|
21
|
+
unavailable: [])
|
|
22
|
+
@configuration = configuration
|
|
23
|
+
# Decided once, here: availability is a property of the run, not of the person a report
|
|
24
|
+
# is for. #fetch seeds @failed_sources from this, so everyone in a batch is told about
|
|
25
|
+
# the same missing sources. `unavailable` carries the ones a caller already dropped
|
|
26
|
+
# before building this orchestrator — see Client#resolve_sources.
|
|
27
|
+
@unavailable_sources = unavailable.map { |e| { source: e[:source], error: e[:error] } }
|
|
28
|
+
@failed_sources = []
|
|
29
|
+
@user_email_for = {} # single-user override (Array<String>), keyed by source_name symbol
|
|
30
|
+
@email_map_for = {} # work-email => Array<source-email> map, keyed by source_name symbol
|
|
31
|
+
@configured_working_hours = {} # per-source fallback, keyed by source_name symbol
|
|
32
|
+
@sources = sources.filter_map do |source_name|
|
|
33
|
+
source_class = SOURCE_MAP[source_name]
|
|
34
|
+
unless source_class
|
|
35
|
+
record_unavailable(source_name, 'unknown source')
|
|
36
|
+
next
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Merge .snitcher.yml statuses into source config
|
|
40
|
+
source_config = source_config_for(source_name).merge(
|
|
41
|
+
config[source_name] || {}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Per-source identity: the emails map (multi-user) translates a requested email
|
|
45
|
+
# into the source-specific email(s); the single `user` is the default identity
|
|
46
|
+
# used only when no email is requested (see #emails_for). Both accept a String
|
|
47
|
+
# or a list of strings (one person committing under several addresses). Both are
|
|
48
|
+
# stripped before the source is constructed.
|
|
49
|
+
email_map = source_config[:emails]
|
|
50
|
+
user_override = source_config[:user]
|
|
51
|
+
source_config = source_config.except(:user, :emails)
|
|
52
|
+
|
|
53
|
+
source = source_class.new(source_config)
|
|
54
|
+
unless source.available?
|
|
55
|
+
record_unavailable(source_name, source.unavailable_reason)
|
|
56
|
+
next
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if email_map
|
|
60
|
+
@email_map_for[source_name] = normalize_email_map(email_map, context: "sources.#{source_name}.emails")
|
|
61
|
+
end
|
|
62
|
+
if user_override
|
|
63
|
+
@user_email_for[source_name] = normalize_emails(user_override, context: "sources.#{source_name}.user")
|
|
64
|
+
end
|
|
65
|
+
@configured_working_hours[source_name] = source_config[:working_hours]
|
|
66
|
+
source
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def fetch(since:, until_time:, user_email: nil)
|
|
71
|
+
requested = normalize_requested(user_email)
|
|
72
|
+
# Seeded, not cleared. A source that raised is this person's outage and must not carry
|
|
73
|
+
# to the next person in the batch; a source that never started is the run's, and belongs
|
|
74
|
+
# on every report this orchestrator produces. #dup so per-fetch entries never accumulate
|
|
75
|
+
# into the per-run list.
|
|
76
|
+
@failed_sources = @unavailable_sources.dup
|
|
77
|
+
|
|
78
|
+
@sources.flat_map do |source|
|
|
79
|
+
emails = emails_for(source.source_name, requested)
|
|
80
|
+
if emails.empty?
|
|
81
|
+
record_dropped_identity(source.source_name) unless requested
|
|
82
|
+
next []
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
apply_working_hours(source, requested)
|
|
86
|
+
source.fetch(user_email: emails, since: since, until_time: until_time)
|
|
87
|
+
rescue Validators::ValidationError => e
|
|
88
|
+
# Validation errors should fail fast - re-raise
|
|
89
|
+
raise e
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
# Record the outage before continuing, so the report can mark itself incomplete
|
|
92
|
+
# instead of presenting a short list as the whole truth.
|
|
93
|
+
@failed_sources << { source: source.source_name, error: "#{e.class}: #{e.message}" }
|
|
94
|
+
# Log error with actionable hint and continue with other sources
|
|
95
|
+
warn "[#{source.source_name}] Error fetching data: #{e.class}: #{e.message}"
|
|
96
|
+
warn ' Check credentials in .env and network connectivity.'
|
|
97
|
+
warn " Backtrace: #{e.backtrace.first(5).join("\n ")}" if ENV['DEBUG']
|
|
98
|
+
[]
|
|
99
|
+
end.sort_by { |item| -(item.completed_at&.to_f || 0) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def available_sources
|
|
103
|
+
@sources.map(&:source_name)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
# Resolves the list of emails a source should filter by, in priority order:
|
|
109
|
+
# 1. the emails map (sources.<name>.emails): a per-user translation from the
|
|
110
|
+
# requested work email to the source-specific identity.
|
|
111
|
+
# 2. otherwise the requested email as-is; if it matches no data the source just
|
|
112
|
+
# returns nothing — we never substitute a different identity. Nothing here
|
|
113
|
+
# can tell whose address was requested, so substituting the configured
|
|
114
|
+
# default would hand one person's data to a report labelled with another's.
|
|
115
|
+
# When NO email is requested the configured default identity (sources.<name>.user)
|
|
116
|
+
# applies — that is the "run it for myself" path, where no one else was named.
|
|
117
|
+
# Returns an Array<String>; empty when nothing is requested and no default is set.
|
|
118
|
+
def emails_for(source_name, requested)
|
|
119
|
+
return Array(@user_email_for[source_name]) unless requested
|
|
120
|
+
|
|
121
|
+
map = @email_map_for[source_name]
|
|
122
|
+
mapped = map && Array(map[requested])
|
|
123
|
+
return mapped if mapped && !mapped.empty?
|
|
124
|
+
|
|
125
|
+
warn_unmapped_identity(source_name, requested)
|
|
126
|
+
[requested]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Points the source at this person's working hours. A person named under
|
|
130
|
+
# working_hours.people gets their own; everyone else is reset to whatever the source
|
|
131
|
+
# was constructed with — the shared block, or a value handed straight to
|
|
132
|
+
# Orchestrator.new(config:). Resetting rather than leaving the slot alone is what
|
|
133
|
+
# stops one person's override from carrying into the next person in the batch.
|
|
134
|
+
def apply_working_hours(source, requested)
|
|
135
|
+
source.working_hours = @configuration.working_hours_override(requested) ||
|
|
136
|
+
@configured_working_hours[source.source_name]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Records a source that never started. Mirrors the rescue in #fetch — same entry shape,
|
|
140
|
+
# same channel — because a consumer cannot act on the difference between "asked and it
|
|
141
|
+
# broke" and "never asked, it was not there": either way the items are missing. Called
|
|
142
|
+
# right before the `next` that drops the source in #initialize's filter_map; the nil
|
|
143
|
+
# this returns is discarded, since `next` has already exited the block by then.
|
|
144
|
+
def record_unavailable(source_name, error)
|
|
145
|
+
@unavailable_sources << { source: source_name, error: error }
|
|
146
|
+
warn "[#{source_name}] Skipped: #{error}."
|
|
147
|
+
warn ' Its items are missing from every report in this run.'
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Records a source skipped on the no-email path because its `user` was configured but
|
|
152
|
+
# lost every address to validation (see #normalize_emails). Per fetch, not per run like
|
|
153
|
+
# #record_unavailable: a named email never reads `user`, so a batch is unaffected. No
|
|
154
|
+
# `user` at all stays unlisted — there is no one to report on, and nothing is broken.
|
|
155
|
+
def record_dropped_identity(source_name)
|
|
156
|
+
return unless @user_email_for.key?(source_name)
|
|
157
|
+
|
|
158
|
+
@failed_sources << { source: source_name, error: "no valid address in sources.#{source_name}.user" }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Warns once per source when a named email has no mapping while a default identity
|
|
162
|
+
# is configured. That combination used to silently swap in the default; the report
|
|
163
|
+
# now filters by the requested address, so a setup relying on the old behaviour
|
|
164
|
+
# gets an empty section instead of a wrong one. The hint names the fix.
|
|
165
|
+
def warn_unmapped_identity(source_name, requested)
|
|
166
|
+
default = Array(@user_email_for[source_name])
|
|
167
|
+
return if default.empty?
|
|
168
|
+
|
|
169
|
+
@warned_unmapped ||= {}
|
|
170
|
+
return if @warned_unmapped[source_name]
|
|
171
|
+
|
|
172
|
+
@warned_unmapped[source_name] = true
|
|
173
|
+
warn "[#{source_name}] No sources.#{source_name}.emails entry for #{requested} — " \
|
|
174
|
+
"filtering by that address. Add \"#{requested}: #{default.join(', ')}\" " \
|
|
175
|
+
"under sources.#{source_name}.emails if it is the same person."
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Validates the requested email when present; nil/blank means "not requested",
|
|
179
|
+
# deferring to the per-source default in #emails_for.
|
|
180
|
+
def normalize_requested(user_email)
|
|
181
|
+
return nil if user_email.nil? || user_email.to_s.strip.empty?
|
|
182
|
+
|
|
183
|
+
Validators.validate_email!(user_email)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Coerces the config map to { String => Array<String> }, tolerating symbol/string
|
|
187
|
+
# keys and String-or-Array values (one person committing under several addresses).
|
|
188
|
+
# A malformed entry is warned about and dropped rather than raised: this map holds
|
|
189
|
+
# every person's identity for this source, so one typo must not cost everyone else
|
|
190
|
+
# their report (see #normalize_emails). An entry left with no valid address is left out
|
|
191
|
+
# of the map altogether, so that person resolves exactly as if they had no entry.
|
|
192
|
+
def normalize_email_map(raw, context:)
|
|
193
|
+
return {} unless raw.is_a?(Hash)
|
|
194
|
+
|
|
195
|
+
raw.each_with_object({}) do |(work, git), map|
|
|
196
|
+
key = work.to_s
|
|
197
|
+
emails = normalize_emails(git, context: "#{context}[#{key}]")
|
|
198
|
+
map[key] = emails unless emails.empty?
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Coerces a String or Array of emails into a validated, deduped Array<String>.
|
|
203
|
+
# An invalid entry is warned about and skipped rather than raised, so it only
|
|
204
|
+
# empties out the mapping it belongs to instead of aborting Orchestrator.new
|
|
205
|
+
# before any report in the batch has run.
|
|
206
|
+
def normalize_emails(value, context:)
|
|
207
|
+
Array(value).filter_map do |email|
|
|
208
|
+
Validators.validate_email!(email.to_s)
|
|
209
|
+
rescue Validators::ValidationError => e
|
|
210
|
+
warn "[Snitcher] Warning: #{e.message} in #{context}. That address is dropped, " \
|
|
211
|
+
'so items under it are missing from reports.'
|
|
212
|
+
nil
|
|
213
|
+
end.uniq
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Returns all non-empty config keys for a source from .snitcher.yml.
|
|
217
|
+
# Passes through status keys, project/space filters, and any future keys.
|
|
218
|
+
# Injects global working_hours config — consumed by Jira, Linear, and ClickUp
|
|
219
|
+
# for filtering calculated time to business hours. Update when adding new sources.
|
|
220
|
+
def source_config_for(source_name)
|
|
221
|
+
return {} unless @configuration.exists?
|
|
222
|
+
|
|
223
|
+
@configuration.source_with_working_hours(source_name)
|
|
224
|
+
.reject { |_k, v| v.respond_to?(:empty?) && v.empty? }
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'erb'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module Snitcher
|
|
8
|
+
module Presenters
|
|
9
|
+
class Html
|
|
10
|
+
include Presenter
|
|
11
|
+
|
|
12
|
+
ALLOWED_URL_SCHEMES = %w[http https mailto].freeze
|
|
13
|
+
TRACKER_SOURCES = %i[jira linear clickup].freeze
|
|
14
|
+
|
|
15
|
+
def render
|
|
16
|
+
render_time = Time.now
|
|
17
|
+
stats, grouped = compute_stats_and_groups
|
|
18
|
+
logo_data_uri = load_logo_data_uri
|
|
19
|
+
template_path = File.expand_path('../templates/report.html.erb', __dir__)
|
|
20
|
+
ERB.new(File.read(template_path)).result(binding)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
SEVERITY_COLORS = { critical: '#c0392b', warning: '#d35400', info: '#2c7be5' }.freeze
|
|
24
|
+
def severity_color(severity) = SEVERITY_COLORS.fetch(severity, '#555')
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def compute_stats_and_groups
|
|
29
|
+
total_seconds = 0
|
|
30
|
+
meeting_seconds = 0
|
|
31
|
+
tracker_items = 0
|
|
32
|
+
commits_count = 0
|
|
33
|
+
grouped = {}
|
|
34
|
+
|
|
35
|
+
items.each do |item|
|
|
36
|
+
# Meetings render in their own aggregated section, not a per-source table, and are
|
|
37
|
+
# excluded from tracked-time totals (reported separately as Meeting Hours).
|
|
38
|
+
if item.source == MeetingsAggregator::MEETING_SOURCE
|
|
39
|
+
meeting_seconds += item.time_spent_seconds || 0
|
|
40
|
+
next
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
total_seconds += item.time_spent_seconds || 0
|
|
44
|
+
tracker_items += 1 if TRACKER_SOURCES.include?(item.source)
|
|
45
|
+
commits_count += 1 if item.source == :git
|
|
46
|
+
(grouped[item.source] ||= []) << item
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
hours_logged = (total_seconds / TimeFormatter::SECONDS_PER_HOUR.to_f).round
|
|
50
|
+
meeting_hours = (meeting_seconds / TimeFormatter::SECONDS_PER_HOUR.to_f).round
|
|
51
|
+
|
|
52
|
+
[{ total_seconds: total_seconds, tracker_items: tracker_items,
|
|
53
|
+
commits_count: commits_count, hours_logged: hours_logged,
|
|
54
|
+
meeting_seconds: meeting_seconds, meeting_hours: meeting_hours,
|
|
55
|
+
meeting_aggregate: MeetingsAggregator.aggregate(items) }, grouped]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def load_logo_data_uri
|
|
59
|
+
path = File.expand_path('../templates/snitcher-logo.png', __dir__)
|
|
60
|
+
"data:image/png;base64,#{Base64.strict_encode64(File.binread(path))}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def safe_url(url)
|
|
64
|
+
return nil unless url
|
|
65
|
+
|
|
66
|
+
uri = URI.parse(url)
|
|
67
|
+
ALLOWED_URL_SCHEMES.include?(uri.scheme&.downcase) ? ERB::Util.html_escape(url) : nil
|
|
68
|
+
rescue URI::InvalidURIError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def item_label(source, count)
|
|
73
|
+
word = source == :git ? 'commit' : 'ticket'
|
|
74
|
+
count == 1 ? word : "#{word}s"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Escapes the AI narrative, then re-applies a minimal safe markdown subset:
|
|
78
|
+
# newlines to <br>, **bold** to <strong>. Escaping happens first, so the
|
|
79
|
+
# re-applied tags can never be used to inject markup from the narrative text.
|
|
80
|
+
def render_narrative_html(narrative)
|
|
81
|
+
ERB::Util.html_escape(narrative)
|
|
82
|
+
.gsub("\n", '<br>')
|
|
83
|
+
.gsub(/\*\*(.+?)\*\*/, '<strong>\1</strong>')
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Snitcher
|
|
4
|
+
module Presenters
|
|
5
|
+
class Markdown
|
|
6
|
+
include Presenter
|
|
7
|
+
|
|
8
|
+
def render
|
|
9
|
+
sections = []
|
|
10
|
+
sections << render_header if header_available?
|
|
11
|
+
sections << render_narrative if config[:narrative]
|
|
12
|
+
sections << render_summary
|
|
13
|
+
sections << render_by_source if non_meeting_items.any?
|
|
14
|
+
sections << render_meetings if MeetingsAggregator.any?(items)
|
|
15
|
+
sections << render_all_items if non_meeting_items.any?
|
|
16
|
+
sections << render_anomalies if anomalies.any?
|
|
17
|
+
sections.join("\n\n")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def render_anomalies
|
|
23
|
+
headers = %w[Severity Signal Details]
|
|
24
|
+
rows = anomalies.map { |a| [escape_md(a.severity.to_s), escape_md(a.title), escape_md(a.description)] }
|
|
25
|
+
"## Potential Problems\n\n#{format_table(headers, rows)}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def non_meeting_items
|
|
29
|
+
@non_meeting_items ||= MeetingsAggregator.non_meeting_items(items)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def header_available?
|
|
33
|
+
config[:user_email] || config[:since] || config[:until_time]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def render_header
|
|
37
|
+
lines = ["# Snitcher Report\n"]
|
|
38
|
+
lines << "**User**: #{escape_md(config[:user_email].to_s)}" if config[:user_email]
|
|
39
|
+
if config[:since] && config[:until_time]
|
|
40
|
+
since_str = config[:since].strftime('%Y-%m-%d')
|
|
41
|
+
# until_time is exclusive (already offset by +1 day); subtract to get display date
|
|
42
|
+
until_str = (config[:until_time] - TimeFormatter::SECONDS_PER_DAY).strftime('%Y-%m-%d')
|
|
43
|
+
lines << "**Period**: #{since_str} \u2013 #{until_str}" # en-dash (U+2013) is intentional
|
|
44
|
+
end
|
|
45
|
+
lines << "**Generated**: #{Time.now.strftime('%Y-%m-%d %H:%M')}"
|
|
46
|
+
lines.join("\n")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def render_narrative
|
|
50
|
+
"## Overview\n\n#{config[:narrative]}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def items_by_source
|
|
54
|
+
@items_by_source ||= non_meeting_items.group_by(&:source)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def render_summary
|
|
58
|
+
sources = items.map(&:source).uniq
|
|
59
|
+
|
|
60
|
+
headers = %w[Metric Value]
|
|
61
|
+
rows = [
|
|
62
|
+
['Total items', items.count.to_s],
|
|
63
|
+
['Sources', sources.map { |s| escape_md(s.to_s) }.join(', ')]
|
|
64
|
+
]
|
|
65
|
+
rows << ['Total tracked time', format_duration(total_seconds)] if total_seconds.positive?
|
|
66
|
+
if MeetingsAggregator.any?(items)
|
|
67
|
+
m = MeetingsAggregator.aggregate(items)
|
|
68
|
+
rows << ['Meetings', "#{format_duration(m[:total_seconds])} across #{m[:event_count]} events"]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
"## Summary\n\n#{format_table(headers, rows)}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Dedicated Meetings section: a total plus one row per recurring series (collapsed).
|
|
75
|
+
def render_meetings
|
|
76
|
+
m = MeetingsAggregator.aggregate(items)
|
|
77
|
+
|
|
78
|
+
headers = %w[Meeting Count Time]
|
|
79
|
+
rows = m[:rows].map do |row|
|
|
80
|
+
[escape_md(truncate(row[:title], TITLE_TRUNCATE_LENGTH_BY_SOURCE)),
|
|
81
|
+
row[:count].to_s,
|
|
82
|
+
format_duration(row[:total_seconds])]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
total_line = "**Total**: #{format_duration(m[:total_seconds])} across #{m[:event_count]} events"
|
|
86
|
+
"## Meetings\n\n#{total_line}\n\n#{format_table(headers, rows)}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def render_by_source
|
|
90
|
+
sections = ['## Work Items by Source']
|
|
91
|
+
items_by_source.each do |source, source_items|
|
|
92
|
+
sections << render_source_section(source, source_items)
|
|
93
|
+
end
|
|
94
|
+
sections.join("\n\n")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def render_source_section(source, source_items)
|
|
98
|
+
headers = ['Ticket', 'Summary', 'Time Spent', 'Completed']
|
|
99
|
+
rows = source_items.map do |item|
|
|
100
|
+
[
|
|
101
|
+
format_ticket(item),
|
|
102
|
+
escape_md(truncate(item.title, TITLE_TRUNCATE_LENGTH_BY_SOURCE)),
|
|
103
|
+
item.time_spent_human || '-',
|
|
104
|
+
item.completed_date || '-'
|
|
105
|
+
]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
"### #{escape_md(source.to_s.capitalize)}\n\n#{format_table(headers, rows)}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def render_all_items
|
|
112
|
+
headers = ['Source', 'Ticket', 'Summary', 'Time Spent', 'Completed']
|
|
113
|
+
rows = non_meeting_items.map do |item|
|
|
114
|
+
[
|
|
115
|
+
escape_md(item.source),
|
|
116
|
+
format_ticket(item),
|
|
117
|
+
escape_md(truncate(item.title, TITLE_TRUNCATE_LENGTH)),
|
|
118
|
+
item.time_spent_human || '-',
|
|
119
|
+
item.completed_date || '-'
|
|
120
|
+
]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
"## All Items\n\n#{format_table(headers, rows)}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Formats a work item's ticket ID, linking it to the item URL when available.
|
|
127
|
+
# Escapes parentheses and pipes in URLs to prevent markdown syntax breakage.
|
|
128
|
+
# @param item [WorkItem]
|
|
129
|
+
# @return [String] plain escaped ID or markdown link
|
|
130
|
+
def format_ticket(item)
|
|
131
|
+
if item.url
|
|
132
|
+
escaped_url = item.url.gsub('(', '%28').gsub(')', '%29').gsub('|', '%7C').gsub(/[\r\n]/, '')
|
|
133
|
+
"[#{escape_md(item.external_id)}](#{escaped_url})"
|
|
134
|
+
else
|
|
135
|
+
escape_md(item.external_id)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|