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