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,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Snitcher
4
+ module Presenters
5
+ module Presenter
6
+ include TimeFormatter
7
+
8
+ TITLE_TRUNCATE_LENGTH = 50
9
+ TITLE_TRUNCATE_LENGTH_BY_SOURCE = 60
10
+
11
+ def self.included(base)
12
+ base.class_eval do
13
+ private
14
+
15
+ attr_reader :items, :config
16
+ end
17
+ end
18
+
19
+ def initialize(items, config = {})
20
+ @items = items
21
+ @config = config
22
+ end
23
+
24
+ private
25
+
26
+ # Tracked work time only — meetings are reported separately (see #meetings_total_seconds).
27
+ def total_seconds
28
+ MeetingsAggregator.non_meeting_items(items).sum { |i| i.time_spent_seconds || 0 }
29
+ end
30
+
31
+ def meetings_total_seconds
32
+ MeetingsAggregator.total_seconds(items)
33
+ end
34
+
35
+ # Detected anomalies, computed once by Report and passed through config.
36
+ def anomalies
37
+ config[:anomalies] || []
38
+ end
39
+
40
+ def truncate(str, length)
41
+ return str if str.nil? || str.length <= length
42
+
43
+ "#{str[0, length - 3]}..."
44
+ end
45
+
46
+ # Build an aligned pipe-delimited table from headers and row data.
47
+ # Computes max width per column and pads cells accordingly.
48
+ #
49
+ # @param headers [Array<String>] column header labels
50
+ # @param rows [Array<Array<String>>] data rows (each an array of cell strings)
51
+ # @return [String] the formatted table
52
+ def format_table(headers, rows)
53
+ col_widths = headers.map(&:length)
54
+ rows.each do |row|
55
+ row.each_with_index do |cell, i|
56
+ len = cell.to_s.length
57
+ col_widths[i] = len if col_widths[i] && len > col_widths[i]
58
+ end
59
+ end
60
+
61
+ lines = []
62
+ lines << format_table_row(headers, col_widths)
63
+ lines << "| #{col_widths.map { |w| '-' * w }.join(' | ')} |"
64
+ rows.each { |row| lines << format_table_row(row, col_widths) }
65
+ lines.join("\n")
66
+ end
67
+
68
+ # Format a single table row with padded cells.
69
+ #
70
+ # @param cells [Array<String>] cell values for the row
71
+ # @param col_widths [Array<Integer>] column widths to pad to
72
+ # @return [String] a pipe-delimited, padded row string
73
+ def format_table_row(cells, col_widths)
74
+ padded = col_widths.map.with_index do |width, i|
75
+ cells[i].to_s.ljust(width)
76
+ end
77
+ "| #{padded.join(' | ')} |"
78
+ end
79
+
80
+ # Escape a value for safe embedding in a markdown table cell.
81
+ # Strips newlines/carriage returns and escapes pipes, HTML tags,
82
+ # and markdown link/image syntax characters.
83
+ def escape_md(str)
84
+ return str if str.nil?
85
+
86
+ str.to_s.gsub(/[\r\n]+/, ' ').gsub(/[|<>\[\]!`]/) { |c| "\\#{c}" }
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module Snitcher
6
+ class Report
7
+ PRESENTER_MAP = {
8
+ markdown: Presenters::Markdown,
9
+ html: Presenters::Html
10
+ }.freeze
11
+
12
+ # Every format #render accepts, in two kinds: :hash is the data a caller computes
13
+ # with; the presenter formats are documents a person reads, which ReportService
14
+ # writes to disk (see ReportWriter). A caller that wants JSON serializes the payload
15
+ # itself — one to_json call, and the choice of serializer stays with the host.
16
+ FORMATS = (%i[hash] + PRESENTER_MAP.keys).freeze
17
+
18
+ # Lets a caller reject a bad format before doing the expensive fetch, rather than
19
+ # discovering it once every source has already been queried.
20
+ def self.supported_format?(format) = FORMATS.include?(format)
21
+
22
+ def initialize(items, format: :hash, config: {})
23
+ @items = items
24
+ @format = format
25
+ @config = config
26
+ end
27
+
28
+ # @return [Hash] for :hash, [String] for every other format.
29
+ def render
30
+ case @format
31
+ when :hash then json_payload
32
+ else presenter_for(@format).render
33
+ end
34
+ end
35
+
36
+ # Public: the fact payload as a Hash (symbol keys). What :hash renders, and the
37
+ # value ReportService hands back for every format — a document format adds
38
+ # metadata[:report_path] to this same Hash.
39
+ def json_payload
40
+ {
41
+ metadata: render_json_metadata,
42
+ anomalies: anomalies.map(&:to_h),
43
+ items: @items.map { |item| item_payload(item) },
44
+ summary: summary_payload
45
+ }
46
+ end
47
+
48
+ private
49
+
50
+ def presenter_for(format)
51
+ klass = PRESENTER_MAP[format]
52
+ raise ArgumentError, "Unknown format: #{format}" unless klass
53
+
54
+ klass.new(@items, @config.merge(anomalies: anomalies))
55
+ end
56
+
57
+ # Detected anomalies, computed once. Returns empty when anomaly detection is disabled.
58
+ def anomalies
59
+ return [] unless @config.fetch(:anomalies_enabled, true)
60
+
61
+ @anomalies ||= AnomalyDetector.new(@items, since: @config[:since],
62
+ until_time: @config[:until_time],
63
+ working_hours: @config[:working_hours]).detect
64
+ end
65
+
66
+ def render_json_metadata
67
+ meta = { generated_at: Time.now.iso8601 }
68
+ meta[:user_email] = @config[:user_email] if @config[:user_email]
69
+ if @config[:since] && @config[:until_time]
70
+ meta[:period_from] = @config[:since].strftime('%Y-%m-%d')
71
+ meta[:period_to] = (@config[:until_time] - TimeFormatter::SECONDS_PER_DAY).strftime('%Y-%m-%d')
72
+ end
73
+ # Present only when something went wrong, so a consumer can treat its absence as
74
+ # "every requested source answered" rather than "this build has no such field".
75
+ failed = Array(@config[:failed_sources])
76
+ meta[:failed_sources] = failed.map { |f| { source: f[:source].to_s, error: f[:error].to_s } } if failed.any?
77
+ meta
78
+ end
79
+
80
+ def item_payload(item)
81
+ {
82
+ source: item.source,
83
+ external_id: item.external_id,
84
+ title: item.title,
85
+ completed_at: item.completed_at&.iso8601,
86
+ time_spent_seconds: item.time_spent_seconds,
87
+ time_spent_human: item.time_spent_human,
88
+ url: item.url,
89
+ metadata: item.metadata
90
+ }
91
+ end
92
+
93
+ def summary_payload
94
+ {
95
+ total_items: @items.count,
96
+ sources: @items.map(&:source).uniq,
97
+ # Tracked work time only — meetings are reported separately (see meetings below).
98
+ total_time_seconds: MeetingsAggregator.non_meeting_items(@items).sum { |i| i.time_spent_seconds || 0 },
99
+ meetings_total_seconds: meetings_aggregate[:total_seconds],
100
+ meetings: {
101
+ total_seconds: meetings_aggregate[:total_seconds],
102
+ event_count: meetings_aggregate[:event_count],
103
+ by_series: meetings_aggregate[:rows]
104
+ }
105
+ }
106
+ end
107
+
108
+ def meetings_aggregate
109
+ @meetings_aggregate ||= MeetingsAggregator.aggregate(@items)
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module Snitcher
6
+ # Turns plain parameters into a rendered report, reusing Orchestrator + Report.
7
+ # Raises on invalid input (never calls exit) so callers can surface errors.
8
+ class ReportService
9
+ SECONDS_PER_DAY = TimeFormatter::SECONDS_PER_DAY
10
+
11
+ # Runs one report. Every format returns the fact payload (Hash), with
12
+ # metadata.report_path set when a document was written.
13
+ # rubocop:disable-next Metrics/ParameterLists -- keyword args are self-documenting
14
+ def self.call(from:, user_email: nil, to: nil, sources: nil, include_anomalies: true,
15
+ format: :hash, narrative: nil, output_dir: nil, output_path: nil,
16
+ orchestrator: nil, configuration: nil)
17
+ new(configuration: configuration).call(
18
+ user_email: user_email, from: from, to: to, sources: sources,
19
+ include_anomalies: include_anomalies, format: format, narrative: narrative,
20
+ output_dir: output_dir, output_path: output_path, orchestrator: orchestrator
21
+ )
22
+ end
23
+
24
+ # Coerces a String or Symbol format and rejects anything Report cannot render.
25
+ # Called before any source is queried, so a typo costs nothing.
26
+ def self.normalize_format(format)
27
+ sym = (format || :hash).to_sym
28
+ unless Report.supported_format?(sym)
29
+ raise ArgumentError,
30
+ "Unknown format: #{format}. Available: #{Report::FORMATS.join(', ')}"
31
+ end
32
+
33
+ sym
34
+ end
35
+
36
+ def initialize(configuration: nil)
37
+ @configuration = configuration || Configuration.new
38
+ end
39
+
40
+ # rubocop:disable Metrics/ParameterLists -- keyword args are self-documenting
41
+ def call(from:, to:, sources:, include_anomalies:, user_email: nil, format: :hash,
42
+ narrative: nil, output_dir: nil, output_path: nil, orchestrator: nil)
43
+ # rubocop:enable Metrics/ParameterLists
44
+ fmt = self.class.normalize_format(format)
45
+ since, until_time = parse_date_range(from, to)
46
+ source_syms = resolve_sources(sources)
47
+ orch = orchestrator || Orchestrator.new(sources: source_syms, configuration: @configuration)
48
+ items = orch.fetch(user_email: user_email, since: since, until_time: until_time)
49
+
50
+ config = report_config(user_email, since, until_time, include_anomalies,
51
+ orch.failed_sources, narrative)
52
+ report = Report.new(items, format: fmt, config: config)
53
+ return report.render unless ReportWriter.saved_format?(fmt)
54
+
55
+ save(report, fmt, user_email, since, until_time, output_dir, output_path)
56
+ end
57
+
58
+ private
59
+
60
+ # Writes the rendered document and hands back the fact payload carrying its location,
61
+ # so a caller always gets the same Hash to work with whatever format it asked for.
62
+ # rubocop:disable-next Metrics/ParameterLists -- one argument per filename component
63
+ def save(report, format, user_email, since, until_time, output_dir, output_path)
64
+ path = ReportWriter.new(configuration: @configuration, output_dir: output_dir).write(
65
+ report.render, format: format, user_email: user_email,
66
+ since: since, until_time: until_time, output_path: output_path
67
+ )
68
+ payload = report.json_payload
69
+ payload[:metadata][:report_path] = path
70
+ payload[:metadata][:report_format] = format.to_s
71
+ payload
72
+ end
73
+
74
+ # Raises on a missing or malformed range instead of exiting.
75
+ def parse_date_range(from, to)
76
+ raise ArgumentError, "'from' is required (YYYY-MM-DD)" if from.nil? || from.empty?
77
+
78
+ since = parse_date(from)
79
+ to_str = to.nil? || to.empty? ? Time.now.strftime('%Y-%m-%d') : to
80
+ until_date = parse_date(to_str)
81
+ raise ArgumentError, "'from' (#{from}) must not be after 'to' (#{to_str})" unless since <= until_date
82
+
83
+ # +1 day so the inclusive 'to' date is fully covered (until_time is exclusive).
84
+ [since, until_date + SECONDS_PER_DAY]
85
+ end
86
+
87
+ # Wraps only genuine parse failures — keeps guard messages (missing/ordered
88
+ # dates) intact instead of re-labelling them as "Invalid date".
89
+ def parse_date(str)
90
+ Time.parse(str)
91
+ rescue ArgumentError => e
92
+ raise ArgumentError, "Invalid date '#{str}': #{e.message}"
93
+ end
94
+
95
+ # Accepts nil, "all", an array, or a comma-separated string. Blank input and an
96
+ # explicit "all" (in any position) resolve to every source.
97
+ def resolve_sources(sources)
98
+ keys = Orchestrator::SOURCE_MAP.keys
99
+ list = Array(sources).flat_map { |s| s.to_s.split(',') }.map(&:strip).reject(&:empty?)
100
+ return keys if list.empty? || list.include?('all')
101
+
102
+ syms = list.map(&:to_sym)
103
+ invalid = syms - keys
104
+ if invalid.any?
105
+ raise ArgumentError,
106
+ "Unknown source(s): #{invalid.join(', ')}. Available: #{keys.join(', ')}"
107
+ end
108
+
109
+ syms
110
+ end
111
+
112
+ # Builds the config Hash consumed by Report. narrative is omitted rather than set to
113
+ # nil, so presenters keep treating "no narrative" as "no Overview section".
114
+ # rubocop:disable-next Metrics/ParameterLists -- a config builder, one key per argument
115
+ def report_config(user_email, since, until_time, include_anomalies, failed_sources, narrative)
116
+ config = {
117
+ user_email: user_email,
118
+ since: since,
119
+ until_time: until_time,
120
+ working_hours: @configuration.working_hours(user_email: user_email),
121
+ anomalies_enabled: include_anomalies,
122
+ failed_sources: failed_sources
123
+ }
124
+ config[:narrative] = narrative if narrative
125
+ config
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'pathname'
5
+
6
+ module Snitcher
7
+ # Writes a rendered report to disk. Markdown and HTML are documents a person opens, not
8
+ # values a host program computes with — handing back a multi-kilobyte String and leaving
9
+ # the host to invent a filename means every host invents a different one. So the gem
10
+ # writes the file and reports where, and the caller still gets the fact payload.
11
+ class ReportWriter
12
+ EXTENSIONS = { markdown: 'md', html: 'html' }.freeze
13
+
14
+ # Written to disk instead of returned as a String.
15
+ SAVED_FORMATS = EXTENSIONS.keys.freeze
16
+
17
+ DEFAULT_OUTPUT_DIR = 'snitcher_reports'
18
+
19
+ # Stands in for the user in a filename when no email was requested (the "run it for
20
+ # myself" path, where identity comes from sources.<name>.user).
21
+ ANONYMOUS_SLUG = 'report'
22
+
23
+ def self.saved_format?(format) = SAVED_FORMATS.include?(format)
24
+
25
+ def initialize(configuration: nil, output_dir: nil)
26
+ @configuration = configuration
27
+ @output_dir = output_dir
28
+ end
29
+
30
+ # Writes content and returns the absolute path it landed at. output_path names the
31
+ # file outright; otherwise the name is derived from the user and the period, so
32
+ # regenerating the same report overwrites it rather than piling up copies.
33
+ # rubocop:disable-next Metrics/ParameterLists -- keyword args are self-documenting
34
+ def write(content, format:, user_email: nil, since: nil, until_time: nil, output_path: nil)
35
+ path = Pathname.new(output_path || default_path(format, user_email, since, until_time)).expand_path
36
+ FileUtils.mkdir_p(path.dirname)
37
+ path.write(content)
38
+ path.to_s
39
+ end
40
+
41
+ private
42
+
43
+ def default_path(format, user_email, since, until_time)
44
+ File.join(resolved_output_dir, "#{filename_for(user_email, since, until_time)}.#{EXTENSIONS.fetch(format)}")
45
+ end
46
+
47
+ # Explicit argument, then output_dir from .snitcher.yml, then a directory under the
48
+ # working directory — never the working directory itself, which is the host
49
+ # application's own root and not ours to scatter files across.
50
+ def resolved_output_dir
51
+ @output_dir || @configuration&.output_dir || DEFAULT_OUTPUT_DIR
52
+ end
53
+
54
+ def filename_for(user_email, since, until_time)
55
+ [slugify(user_email), period_slug(since, until_time)].compact.join('_')
56
+ end
57
+
58
+ def period_slug(since, until_time)
59
+ return nil unless since && until_time
60
+
61
+ last_day = until_time - TimeFormatter::SECONDS_PER_DAY
62
+ "#{since.strftime('%Y-%m-%d')}_#{last_day.strftime('%Y-%m-%d')}"
63
+ end
64
+
65
+ # Keeps the address readable while stripping anything that would nest the file in a
66
+ # directory or confuse a shell: alice@corp.com => alice-at-corp.com.
67
+ def slugify(user_email)
68
+ return ANONYMOUS_SLUG if user_email.nil? || user_email.to_s.strip.empty?
69
+
70
+ slug = user_email.to_s.strip.downcase.gsub('@', '-at-').gsub(/[^a-z0-9._-]+/, '-')
71
+ slug.empty? ? ANONYMOUS_SLUG : slug
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Snitcher
4
+ module Sources
5
+ class ClickUp < Source
6
+ # Reads the tasks that team/{team_id}/task cannot see: those whose List, Folder or
7
+ # Space has been archived. ClickUp drops them from that endpoint whatever the
8
+ # `archived` parameter is set to, so they are reachable only by walking the
9
+ # hierarchy and asking each List for its tasks.
10
+ # Depends on constants and helpers defined in ClickUp: PAGINATION_MAX_PAGES,
11
+ # ARCHIVED_LISTS_MAX, MS_PER_SECOND, #connection, #fetch_spaces, #filter_tasks_by_date.
12
+ # @api private
13
+ module ArchivedLists
14
+ private
15
+
16
+ # Tasks from archived Lists for the whole workspace, not for one person: the
17
+ # orchestrator builds its sources once and walks people sequentially, so a team
18
+ # report pays for the walk once and each person filters the result with
19
+ # #assigned_to?. Keyed by the window because Orchestrator#fetch is public and a
20
+ # host may call it again with a different period.
21
+ def archived_tasks(since:, until_time:)
22
+ window = [since.to_i, until_time.to_i]
23
+ return @archived_tasks if @archived_window == window
24
+
25
+ tasks = fetch_archived_list_tasks(since: since, until_time: until_time)
26
+ @archived_window = window
27
+ @archived_tasks = filter_tasks_by_date(tasks, since: since, until_time: until_time)
28
+ end
29
+
30
+ def fetch_archived_list_tasks(since:, until_time:)
31
+ list_ids = archived_list_ids
32
+ return [] if list_ids.empty?
33
+
34
+ errors = []
35
+ tasks = list_ids.flat_map do |list_id|
36
+ paginate_list_tasks(list_id: list_id, since: since, until_time: until_time)
37
+ rescue Faraday::UnauthorizedError, Faraday::ForbiddenError
38
+ # A revoked token answers every remaining list the same way; degrading past it
39
+ # would report a quiet quarter instead of an outage.
40
+ raise
41
+ rescue Faraday::Error => e
42
+ errors << e
43
+ warn "[clickup] Warning: could not read archived list #{list_id} (#{e.message})"
44
+ []
45
+ end
46
+
47
+ # Keyed on how many lists failed, not on how many tasks came back: most windows
48
+ # close nothing in an archived list, so one timeout among healthy empty answers
49
+ # must not fail the source and discard the live pass with it.
50
+ raise errors.first if errors.size == list_ids.size
51
+
52
+ # With "Tasks in Multiple Lists" one task answers from every list it is in.
53
+ tasks.uniq { |task| task['id'] }
54
+ end
55
+
56
+ def archived_list_ids
57
+ @archived_list_ids ||= begin
58
+ ids = archived_lists.filter_map { |list| validated_id(list['id'], name: 'list_id') }
59
+ if ids.size > ARCHIVED_LISTS_MAX
60
+ warn "[clickup] Warning: #{ids.size} archived lists found; reading the first " \
61
+ "#{ARCHIVED_LISTS_MAX}. Tasks in the rest are missing from this report"
62
+ ids = ids.first(ARCHIVED_LISTS_MAX)
63
+ end
64
+ ids
65
+ end
66
+ end
67
+
68
+ # Every list found this way is read, including the ones reporting task_count 0:
69
+ # that count covers open tasks only. A finished sprint reports 0 and still holds
70
+ # its closed tasks — of twelve such lists sampled on a real workspace, eleven
71
+ # answered with tasks, sixty of them closed.
72
+ def archived_lists
73
+ walk_spaces.flat_map do |space_id, space_archived|
74
+ archived_lists_in_space(space_id, space_archived: space_archived)
75
+ end.uniq { |list| list['id'] }
76
+ end
77
+
78
+ # @return [Array<Array(String, Boolean)>] space id and whether the space is archived
79
+ def walk_spaces
80
+ # Configured space names resolve against the live enumeration (#resolve_space_ids),
81
+ # so a configured space is never an archived one. Honouring the setting here also
82
+ # keeps `spaces:` meaning what it says: the walk stays inside it.
83
+ return @space_ids.to_a.map { |id| [id, false] } if @space_ids.any?
84
+
85
+ spaces = fetch_spaces.map { |space| [space['id'], false] } +
86
+ fetch_archived_spaces.map { |space| [space['id'], true] }
87
+ spaces.filter_map do |raw_id, archived|
88
+ id = validated_id(raw_id, name: 'space_id')
89
+ [id, archived] if id
90
+ end
91
+ end
92
+
93
+ def archived_lists_in_space(space_id, space_archived:)
94
+ folders = fetch_folders(space_id, archived: false) + fetch_folders(space_id, archived: true)
95
+ lists = folders.uniq { |folder| folder['id'] }.flat_map do |folder|
96
+ folder_id = validated_id(folder['id'], name: 'folder_id')
97
+ next [] unless folder_id
98
+
99
+ lists_under("folder/#{folder_id}/list", parent_archived: folder['archived'] || space_archived)
100
+ end
101
+ lists + lists_under("space/#{space_id}/list", parent_archived: space_archived)
102
+ end
103
+
104
+ # A List inside an archived Folder or Space usually carries archived: false of its
105
+ # own — the parent is what was archived — so membership, not the List's own flag,
106
+ # decides. Reading only the archived enumeration would miss those.
107
+ def lists_under(path, parent_archived:)
108
+ fetch_lists(path, archived: true) +
109
+ (parent_archived ? fetch_lists(path, archived: false) : [])
110
+ end
111
+
112
+ # Subfolders arrive in this same flat list, each carrying a parent_folder id, so
113
+ # the hierarchy needs no recursion.
114
+ def fetch_folders(space_id, archived:)
115
+ connection.get("space/#{space_id}/folder", { archived: archived }).body['folders'] || []
116
+ end
117
+
118
+ def fetch_lists(path, archived:)
119
+ connection.get(path, { archived: archived }).body['lists'] || []
120
+ end
121
+
122
+ # Mirrors #fetch_spaces for the other half of the enumeration; archived: true
123
+ # answers with archived spaces only, so the two never overlap.
124
+ def fetch_archived_spaces
125
+ @fetch_archived_spaces ||= connection.get("team/#{@team_id}/space", { archived: true })
126
+ .body['spaces'] || []
127
+ end
128
+
129
+ def paginate_list_tasks(list_id:, since:, until_time:)
130
+ tasks = []
131
+ page = 0
132
+
133
+ loop do
134
+ response = connection.get("list/#{list_id}/task") do |req|
135
+ # No `archived` parameter: it filters by the task's own archived flag rather
136
+ # than its list's, and the same archived list answers with 3 tasks when it is
137
+ # sent and 8 when it is not. The live pass does not send it either.
138
+ req.params = {
139
+ 'include_closed' => true,
140
+ 'date_done_gt' => (since.to_f * MS_PER_SECOND).to_i,
141
+ 'date_done_lt' => (until_time.to_f * MS_PER_SECOND).to_i,
142
+ 'page' => page
143
+ }
144
+ end
145
+
146
+ tasks.concat(response.body['tasks'] || [])
147
+ # This endpoint reports the end of the listing; team/{team_id}/task does not,
148
+ # which is why #paginate_tasks has to recognise a repeated page instead.
149
+ break unless response.body['last_page'] == false
150
+
151
+ page += 1
152
+ break if page >= PAGINATION_MAX_PAGES
153
+ end
154
+
155
+ tasks
156
+ end
157
+
158
+ # team/{team_id}/task?assignees[] matches the assignee field alone, so a task the
159
+ # person only watches or created has never been in the report. Matching more here
160
+ # would make a person's report depend on whether the list was archived.
161
+ def assigned_to?(task, user_id)
162
+ Array(task['assignees']).any? { |assignee| assignee['id'].to_s == user_id.to_s }
163
+ end
164
+
165
+ def validated_id(raw_id, name:)
166
+ Validators.validate_id!(raw_id, name: name)
167
+ rescue Validators::ValidationError => e
168
+ warn "[clickup] Warning: skipping #{name} (#{e.message})"
169
+ nil
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../working_hours_calculator'
4
+
5
+ module Snitcher
6
+ module Sources
7
+ class ClickUp < Source
8
+ # Working-hours interval reconstruction for ClickUp's status-time fallback (Tier 3).
9
+ # Extracted from ClickUp to keep class length under RuboCop's Metrics/ClassLength limit.
10
+ # Depends on constants defined in ClickUp: CUSTOM_STATUS_TYPE, SECONDS_PER_MINUTE, MS_PER_SECOND.
11
+ # @api private
12
+ module WorkingHoursFilter
13
+ private
14
+
15
+ # Returns seconds spent in 'custom' (in-progress) statuses.
16
+ # With working_hours: reconstructs intervals from `since` timestamps and filters.
17
+ # Without: fast path using by_minute sum.
18
+ #
19
+ # @param status_history [Array<Hash>, nil] status history from time_in_status API
20
+ # @param task_closed_at [Time, nil] when the task was closed (end bound for last interval)
21
+ # @return [Integer] seconds spent in in-progress statuses
22
+ def calculate_time_from_status_history(status_history, task_closed_at: nil)
23
+ return 0 if status_history.nil? || status_history.empty?
24
+
25
+ custom_entries = status_history.select { |s| s['type'] == CUSTOM_STATUS_TYPE }
26
+ return 0 if custom_entries.empty?
27
+
28
+ raw_seconds = [custom_entries.sum { |s| s.dig('total_time', 'by_minute').to_i } * SECONDS_PER_MINUTE, 0].max
29
+
30
+ return raw_seconds unless @working_hours
31
+
32
+ filtered_seconds = calculate_filtered_status_time(status_history, task_closed_at: task_closed_at)
33
+ # Cap at raw by_minute; when 0, fall back to raw (ClickUp API limitation:
34
+ # can't distinguish missing timestamps from all-outside-working-hours).
35
+ if filtered_seconds.positive?
36
+ [filtered_seconds, raw_seconds].min
37
+ else
38
+ warn '[Snitcher::ClickUp] Working hours filter returned 0; falling back to raw by_minute' if ENV['DEBUG']
39
+ raw_seconds
40
+ end
41
+ end
42
+
43
+ # Reconstructs time intervals from status_history since timestamps,
44
+ # then filters each interval through WorkingHoursCalculator.
45
+ #
46
+ # Sort all entries chronologically by `since`, then for each 'custom' entry
47
+ # its end time is the next entry's `since` (or task_closed_at for the last).
48
+ #
49
+ # Limitation: ClickUp returns one entry per unique status (not per transition).
50
+ # For bouncing tasks, `since` reflects only the last entry — earlier periods are lost.
51
+ # The caller caps the result at by_minute * SECONDS_PER_MINUTE.
52
+ #
53
+ # @param status_history [Array<Hash>] full status history (all types)
54
+ # @param task_closed_at [Time, nil] task close time (end bound for last interval;
55
+ # when nil, last custom interval is silently dropped)
56
+ # @return [Integer] filtered seconds (0 when no usable since timestamps)
57
+ # @note Caller must ensure @working_hours is non-nil before calling this method.
58
+ def calculate_filtered_status_time(status_history, task_closed_at: nil)
59
+ sorted = status_history
60
+ .select { |s| s.dig('total_time', 'since').to_i.positive? }
61
+ .sort_by { |s| s.dig('total_time', 'since').to_i }
62
+
63
+ return 0 if sorted.empty?
64
+
65
+ total = 0
66
+ sorted.each_with_index do |entry, i|
67
+ next unless entry['type'] == CUSTOM_STATUS_TYPE
68
+
69
+ start_ms = entry.dig('total_time', 'since').to_i
70
+ start_time = Time.at(start_ms / MS_PER_SECOND)
71
+
72
+ next_since_ms = sorted[i + 1]&.dig('total_time', 'since')&.to_i
73
+ end_time = if next_since_ms&.positive?
74
+ Time.at(next_since_ms / MS_PER_SECOND)
75
+ else
76
+ task_closed_at
77
+ end
78
+
79
+ next unless end_time && end_time > start_time
80
+
81
+ total += WorkingHoursCalculator.seconds_between(start_time, end_time, @working_hours)
82
+ end
83
+ total
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end