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