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,467 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'faraday/retry'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'time'
|
|
7
|
+
require_relative 'clickup/working_hours_filter'
|
|
8
|
+
require_relative 'clickup/archived_lists'
|
|
9
|
+
|
|
10
|
+
module Snitcher
|
|
11
|
+
module Sources
|
|
12
|
+
# rubocop:disable-next Metrics/ClassLength -- Length due to required YARD docs and helper methods
|
|
13
|
+
class ClickUp < Source
|
|
14
|
+
include WorkingHoursFilter
|
|
15
|
+
include ArchivedLists
|
|
16
|
+
|
|
17
|
+
BASE_URL = 'https://api.clickup.com/api/v2'
|
|
18
|
+
PAGINATION_MAX_PAGES = 10 # Safety limit to prevent infinite loops
|
|
19
|
+
# Runaway guard for the archived-list walk, not a routine cut: the API returns lists
|
|
20
|
+
# in its own order, so which tasks a truncated walk drops is arbitrary. A real
|
|
21
|
+
# workspace measured 66 archived lists.
|
|
22
|
+
ARCHIVED_LISTS_MAX = 100
|
|
23
|
+
MS_PER_SECOND = 1_000
|
|
24
|
+
# ClickUp status type for user-defined workflow statuses.
|
|
25
|
+
# Status types are language-independent (unlike status names).
|
|
26
|
+
# 'custom' covers all statuses between 'open' (to-do) and 'closed' (done),
|
|
27
|
+
# which makes it the right proxy for "in progress" time regardless of locale.
|
|
28
|
+
CUSTOM_STATUS_TYPE = 'custom'
|
|
29
|
+
SECONDS_PER_MINUTE = 60
|
|
30
|
+
# ClickUp's bulk_time_in_status endpoint serialises task IDs as repeated query params.
|
|
31
|
+
# 100 IDs per batch keeps URLs well under typical server/proxy limits (~8 KB).
|
|
32
|
+
BULK_TASK_IDS_BATCH_SIZE = 100
|
|
33
|
+
|
|
34
|
+
def initialize(config = {})
|
|
35
|
+
super
|
|
36
|
+
# Use presence check to handle empty strings from ENV
|
|
37
|
+
@api_token = Source.presence(config[:api_token]) || Source.env('CLICKUP_API_TOKEN')
|
|
38
|
+
raw_team_id = Source.presence(config[:team_id]) || Source.env('CLICKUP_TEAM_ID')
|
|
39
|
+
# Validate team_id to prevent URL path injection
|
|
40
|
+
@team_id = raw_team_id ? Validators.validate_id!(raw_team_id, name: 'team_id') : nil
|
|
41
|
+
# Status names to filter for closed tasks (from .snitcher.yml or default)
|
|
42
|
+
@done_statuses = config[:done_statuses] || ['closed']
|
|
43
|
+
# Space names from config; resolved to IDs at init time (one API call).
|
|
44
|
+
# Stored as a Set for O(1) membership checks (see filter_time_entries convention).
|
|
45
|
+
# Faraday::Error is rescued here so a ClickUp API outage does not crash the entire
|
|
46
|
+
# CLI run — only this source is degraded, Jira/Linear/Git continue normally.
|
|
47
|
+
space_names = config[:spaces] || []
|
|
48
|
+
@space_ids = if space_names.any? && available?
|
|
49
|
+
begin
|
|
50
|
+
ids = resolve_space_ids(space_names)
|
|
51
|
+
if ids.empty?
|
|
52
|
+
warn '[clickup] Warning: none of the configured spaces could be resolved; ' \
|
|
53
|
+
'fetching from all spaces'
|
|
54
|
+
end
|
|
55
|
+
Set.new(ids)
|
|
56
|
+
rescue Faraday::Error => e
|
|
57
|
+
warn "[clickup] Warning: could not resolve space names (#{e.message}); " \
|
|
58
|
+
'fetching from all spaces'
|
|
59
|
+
Set.new
|
|
60
|
+
end
|
|
61
|
+
else
|
|
62
|
+
Set.new
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Discover all statuses across ClickUp spaces for the configured workspace.
|
|
67
|
+
# Uses language-independent `type` field to categorize statuses.
|
|
68
|
+
# Currently unused in fetch flow — kept for potential config discovery use.
|
|
69
|
+
#
|
|
70
|
+
# @param config [Hash] optional config overrides
|
|
71
|
+
# @return [Hash] status discovery result
|
|
72
|
+
def self.discover_statuses(config = {})
|
|
73
|
+
instance = new(config)
|
|
74
|
+
return { error: 'ClickUp not configured' } unless instance.available?
|
|
75
|
+
|
|
76
|
+
instance.send(:fetch_space_statuses)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Discover all ClickUp spaces for the configured workspace.
|
|
80
|
+
# Used during `snitcher init` to present available spaces to the user.
|
|
81
|
+
#
|
|
82
|
+
# @param config [Hash] optional config overrides (api_token:, team_id:)
|
|
83
|
+
# @return [Array<Hash>, Hash] array of {id:, name:} hashes, or {error: "..."} on failure
|
|
84
|
+
def self.discover_spaces(config = {})
|
|
85
|
+
instance = new(config)
|
|
86
|
+
return { error: 'ClickUp not configured' } unless instance.available?
|
|
87
|
+
|
|
88
|
+
spaces = instance.send(:fetch_spaces)
|
|
89
|
+
spaces.map { |s| { id: s['id'], name: s['name'] } }
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
{ error: "Failed to fetch ClickUp spaces: #{e.message}" }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def source_name
|
|
95
|
+
:clickup
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def available?
|
|
99
|
+
!!(@api_token && @team_id)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def unavailable_reason
|
|
103
|
+
missing = []
|
|
104
|
+
missing << 'CLICKUP_API_TOKEN' if @api_token.nil?
|
|
105
|
+
missing << 'CLICKUP_TEAM_ID' if @team_id.nil?
|
|
106
|
+
"#{missing.join(', ')} #{missing.one? ? 'is' : 'are'} not set"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def fetch(user_email:, since:, until_time:)
|
|
110
|
+
return [] unless available?
|
|
111
|
+
|
|
112
|
+
user_id = find_user_id(primary_email(user_email))
|
|
113
|
+
return [] unless user_id
|
|
114
|
+
|
|
115
|
+
tasks = fetch_tasks(user_id: user_id, since: since, until_time: until_time)
|
|
116
|
+
time_entries = fetch_time_entries(user_id: user_id, since: since, until_time: until_time)
|
|
117
|
+
|
|
118
|
+
# Three-tier strategy for resolving task time:
|
|
119
|
+
# Tier 1 (:worklog) — time entries API sum > 0
|
|
120
|
+
# Tier 2 (:worklog) — task's time_spent field > 0 (when no tracked entries)
|
|
121
|
+
# Tier 3 (:calculated) — status history fallback via bulk_time_in_status API
|
|
122
|
+
# Tasks without Tier 1/2 data collect their IDs in no_time_ids for Tier 3 resolution.
|
|
123
|
+
|
|
124
|
+
time_by_task = time_entries.group_by { |te| te.dig('task', 'id') }
|
|
125
|
+
results = []
|
|
126
|
+
no_time_ids = []
|
|
127
|
+
tasks.each do |task|
|
|
128
|
+
task_time_entries = time_by_task[task['id']] || []
|
|
129
|
+
# Running timers are already filtered out in fetch_time_entries; .abs is a defensive guard
|
|
130
|
+
# in case any slip through. ClickUp durations are in milliseconds.
|
|
131
|
+
total_time_ms = task_time_entries.sum { |te| te['duration'].to_i.abs }
|
|
132
|
+
|
|
133
|
+
# Use time tracking if available, otherwise use task's time_spent.
|
|
134
|
+
# ClickUp timestamps and durations are in milliseconds.
|
|
135
|
+
time_seconds = if total_time_ms.positive?
|
|
136
|
+
total_time_ms / MS_PER_SECOND
|
|
137
|
+
elsif task['time_spent'].to_i.positive?
|
|
138
|
+
task['time_spent'].to_i / MS_PER_SECOND
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
if time_seconds
|
|
142
|
+
results << build_task_work_item(task, time_spent_seconds: time_seconds, time_source: :worklog)
|
|
143
|
+
else
|
|
144
|
+
no_time_ids << task['id']
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
apply_status_time_fallback(results, no_time_ids, tasks)
|
|
149
|
+
results
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def connection
|
|
155
|
+
@connection ||= Faraday.new(url: BASE_URL) do |f|
|
|
156
|
+
f.request :json
|
|
157
|
+
# Without this an expired token answers every call with a body that simply lacks
|
|
158
|
+
# the expected key, `body['tasks'] || []` turns it into "no work", and the source
|
|
159
|
+
# reports a quiet week instead of an outage — the failure mode Jira's client
|
|
160
|
+
# documents at length (jira/client.rb:18-24). Registered before :retry so it nests
|
|
161
|
+
# outside it: the other way round a 429/5xx becomes an exception before retry can
|
|
162
|
+
# read the status, and retry_statuses never fires. Every ClickUp call here is a
|
|
163
|
+
# GET, so faraday-retry's idempotent-only default needs no widening.
|
|
164
|
+
f.response :raise_error
|
|
165
|
+
f.request :retry, max: 3, interval: 0.5, retry_statuses: [429, 500, 502, 503, 504]
|
|
166
|
+
f.response :json
|
|
167
|
+
f.headers['Authorization'] = @api_token
|
|
168
|
+
f.options.timeout = 15
|
|
169
|
+
f.options.open_timeout = 5
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def find_user_id(email)
|
|
174
|
+
@user_id_cache ||= {}
|
|
175
|
+
return @user_id_cache[email] if @user_id_cache.key?(email)
|
|
176
|
+
|
|
177
|
+
response = connection.get("team/#{@team_id}")
|
|
178
|
+
members = response.body.dig('team', 'members') || []
|
|
179
|
+
|
|
180
|
+
member = members.find { |m| m.dig('user', 'email')&.downcase == email.downcase }
|
|
181
|
+
@user_id_cache[email] = member&.dig('user', 'id')
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The two passes overlap only by accident, but a task ClickUp reports through both
|
|
185
|
+
# would otherwise be counted twice, the way a repeated page used to be.
|
|
186
|
+
def fetch_tasks(user_id:, since:, until_time:)
|
|
187
|
+
all_tasks = paginate_tasks(user_id: user_id, since: since, until_time: until_time)
|
|
188
|
+
seen_task_ids = Set.new(all_tasks.map { |task| task['id'] })
|
|
189
|
+
|
|
190
|
+
# A task with no id is dropped here too, for the reason #paginate_tasks drops it:
|
|
191
|
+
# it cannot be told apart from another id-less task, and admitting it on this path
|
|
192
|
+
# alone would make a task's presence depend on which pass found it.
|
|
193
|
+
# add?, not include?: the assignee test runs first, so a task rejected on it never
|
|
194
|
+
# enters the set.
|
|
195
|
+
from_archived = archived_tasks(since: since, until_time: until_time).select do |task|
|
|
196
|
+
task['id'] && assigned_to?(task, user_id) && seen_task_ids.add?(task['id'])
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
filter_tasks_by_date(all_tasks + from_archived, since: since, until_time: until_time)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def paginate_tasks(user_id:, since:, until_time:)
|
|
203
|
+
all_tasks = []
|
|
204
|
+
seen_task_ids = Set.new
|
|
205
|
+
page = 0
|
|
206
|
+
|
|
207
|
+
loop do
|
|
208
|
+
response = connection.get("team/#{@team_id}/task") do |req|
|
|
209
|
+
req.options.params_encoder = Faraday::FlatParamsEncoder
|
|
210
|
+
# No statuses[] filter: lists and folders can override space-level statuses
|
|
211
|
+
# via the override_statuses flag, introducing custom status names that differ
|
|
212
|
+
# from space-level ones. Filtering by status name across a workspace is therefore
|
|
213
|
+
# unreliable without fetching all lists/folders to build a complete status map.
|
|
214
|
+
# date_done_gt/lt is the correct ClickUp filter for completion date (Unix ms).
|
|
215
|
+
# date_closed_gt/lt is not a valid API parameter and is silently ignored.
|
|
216
|
+
# Client-side filter in filter_tasks_by_date is the authoritative guard.
|
|
217
|
+
req.params = {
|
|
218
|
+
'assignees[]' => user_id,
|
|
219
|
+
'date_done_gt' => (since.to_f * MS_PER_SECOND).to_i,
|
|
220
|
+
'date_done_lt' => (until_time.to_f * MS_PER_SECOND).to_i,
|
|
221
|
+
'include_closed' => true,
|
|
222
|
+
'page' => page
|
|
223
|
+
}
|
|
224
|
+
req.params['space_ids[]'] = @space_ids.to_a if @space_ids.any?
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
tasks = response.body['tasks'] || []
|
|
228
|
+
break if tasks.empty?
|
|
229
|
+
|
|
230
|
+
# Past the last page the API repeats it rather than answering with an empty list —
|
|
231
|
+
# the same behaviour #filter_time_entries dedups against. Without this every repeat
|
|
232
|
+
# was concatenated whole, so one task could arrive PAGINATION_MAX_PAGES times and
|
|
233
|
+
# its hours were counted that many times over. A page carrying nothing new is that
|
|
234
|
+
# repeat, and it is also the only end-of-listing signal the endpoint gives.
|
|
235
|
+
# A task with no id is dropped rather than deduped, so it can't collapse with an
|
|
236
|
+
# unrelated id-less task and silently under-count the other one's hours.
|
|
237
|
+
fresh = tasks.select { |task| task['id'] && seen_task_ids.add?(task['id']) }
|
|
238
|
+
break if fresh.empty?
|
|
239
|
+
|
|
240
|
+
all_tasks.concat(fresh)
|
|
241
|
+
page += 1
|
|
242
|
+
break if page >= PAGINATION_MAX_PAGES
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
all_tasks
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Filters tasks to only those closed within the requested range.
|
|
249
|
+
# Defensive guard against tasks with no date_closed (e.g. not yet closed) slipping through.
|
|
250
|
+
def filter_tasks_by_date(tasks, since:, until_time:)
|
|
251
|
+
since_ms = (since.to_f * MS_PER_SECOND).to_i
|
|
252
|
+
until_ms = (until_time.to_f * MS_PER_SECOND).to_i
|
|
253
|
+
|
|
254
|
+
tasks.select do |t|
|
|
255
|
+
closed_ms = t['date_closed'].to_i
|
|
256
|
+
closed_ms.positive? && closed_ms > since_ms && closed_ms < until_ms
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Time entries are fetched workspace-wide. The API supports a singular space_id param
|
|
261
|
+
# but not space_ids[], so filtering by multiple spaces would require one request per
|
|
262
|
+
# space. Instead we rely on the task-ID join in #fetch: tasks are already scoped to
|
|
263
|
+
# the configured spaces via space_ids[], so time entries for other spaces are simply
|
|
264
|
+
# never matched and are discarded. Results are correct; the only cost is fetching
|
|
265
|
+
# more entries than needed in large workspaces.
|
|
266
|
+
def fetch_time_entries(user_id:, since:, until_time:)
|
|
267
|
+
all_entries = []
|
|
268
|
+
seen_entry_ids = Set.new
|
|
269
|
+
page = 0
|
|
270
|
+
|
|
271
|
+
loop do
|
|
272
|
+
response = connection.get("team/#{@team_id}/time_entries") do |req|
|
|
273
|
+
req.params = {
|
|
274
|
+
'assignee' => user_id,
|
|
275
|
+
# ClickUp timestamps are in milliseconds
|
|
276
|
+
'start_date' => (since.to_f * MS_PER_SECOND).to_i,
|
|
277
|
+
'end_date' => (until_time.to_f * MS_PER_SECOND).to_i,
|
|
278
|
+
'page' => page
|
|
279
|
+
}
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
entries = response.body['data'] || []
|
|
283
|
+
break if entries.empty?
|
|
284
|
+
|
|
285
|
+
# Same repeated-last-page behaviour #paginate_tasks guards against: stopping on a
|
|
286
|
+
# page with nothing new avoids walking every remaining page on short result sets.
|
|
287
|
+
fresh = entries.select { |te| seen_entry_ids.add?(te['id']) }
|
|
288
|
+
break if fresh.empty?
|
|
289
|
+
|
|
290
|
+
all_entries.concat(fresh)
|
|
291
|
+
page += 1
|
|
292
|
+
break if page >= PAGINATION_MAX_PAGES
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
filter_time_entries(all_entries, user_id)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Dedup (API repeats last page), drop running timers, keep only this user's entries.
|
|
299
|
+
# Single-pass to avoid three intermediate arrays.
|
|
300
|
+
def filter_time_entries(entries, user_id)
|
|
301
|
+
seen = Set.new
|
|
302
|
+
entries.each_with_object([]) do |te, result|
|
|
303
|
+
next unless seen.add?(te['id'])
|
|
304
|
+
next if te['duration'].to_i.negative?
|
|
305
|
+
next unless te.dig('user', 'id').to_s == user_id.to_s
|
|
306
|
+
|
|
307
|
+
result << te
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def task_metadata(task)
|
|
312
|
+
{ status: task.dig('status', 'status'), priority: task.dig('priority', 'priority'),
|
|
313
|
+
list: task.dig('list', 'name'), space: task.dig('space', 'name'),
|
|
314
|
+
tags: task['tags']&.map { |t| t['name'] } }
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Fetches cumulative time-in-status for multiple tasks.
|
|
318
|
+
# Batches requests to avoid URL length limits. Requires paid ClickUp plan.
|
|
319
|
+
# Partial results from successful batches are preserved even if a later batch fails.
|
|
320
|
+
# On free plans, the API returns HTTP 403; raise_error turns that into a
|
|
321
|
+
# Faraday::Error before response.success? is ever evaluated, and it's the rescue
|
|
322
|
+
# below — not the guard — that keeps the batch loop going.
|
|
323
|
+
# See: https://help.clickup.com/hc/en-us/articles/6304185469719
|
|
324
|
+
def fetch_bulk_time_in_status(task_ids)
|
|
325
|
+
return {} if task_ids.empty?
|
|
326
|
+
|
|
327
|
+
result = {}
|
|
328
|
+
task_ids.each_slice(BULK_TASK_IDS_BATCH_SIZE) do |batch|
|
|
329
|
+
response = connection.get('task/bulk_time_in_status/task_ids') do |req|
|
|
330
|
+
# FlatParamsEncoder serialises the array as repeated params:
|
|
331
|
+
# task_ids=id1&task_ids=id2 — the format expected by ClickUp's bulk endpoint.
|
|
332
|
+
req.options.params_encoder = Faraday::FlatParamsEncoder
|
|
333
|
+
req.params = { 'task_ids' => batch }
|
|
334
|
+
end
|
|
335
|
+
next unless response.body.is_a?(Hash)
|
|
336
|
+
|
|
337
|
+
result.merge!(response.body)
|
|
338
|
+
rescue Faraday::Error => e
|
|
339
|
+
warn "[clickup] bulk_time_in_status batch failed: #{e.message}"
|
|
340
|
+
end
|
|
341
|
+
result
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Applies status-time fallback for tasks with no tracked time (Tier 3).
|
|
345
|
+
# Requires paid ClickUp plan; on free plans tasks are added with nil time.
|
|
346
|
+
#
|
|
347
|
+
# @param results [Array<WorkItem>] items already built (with time)
|
|
348
|
+
# @param no_time_ids [Array<String>] task IDs that had no time data
|
|
349
|
+
# @param tasks [Array<Hash>] all fetched task hashes (for metadata)
|
|
350
|
+
def apply_status_time_fallback(results, no_time_ids, tasks)
|
|
351
|
+
return if no_time_ids.empty?
|
|
352
|
+
|
|
353
|
+
status_data = fetch_bulk_time_in_status(no_time_ids)
|
|
354
|
+
tasks_by_id = tasks.to_h { |t| [t['id'], t] }
|
|
355
|
+
no_time_ids.each do |task_id|
|
|
356
|
+
task = tasks_by_id[task_id]
|
|
357
|
+
next unless task
|
|
358
|
+
|
|
359
|
+
closed_at = parse_closed_at(task)
|
|
360
|
+
seconds = calculate_time_from_status_history(
|
|
361
|
+
status_data.dig(task_id, 'status_history'),
|
|
362
|
+
task_closed_at: closed_at
|
|
363
|
+
)
|
|
364
|
+
results << build_task_work_item(
|
|
365
|
+
task,
|
|
366
|
+
time_spent_seconds: seconds.positive? ? seconds : nil,
|
|
367
|
+
time_source: seconds.positive? ? :calculated : nil,
|
|
368
|
+
closed_at: closed_at
|
|
369
|
+
)
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# @param task [Hash] ClickUp task hash
|
|
374
|
+
# @return [Time, nil] task close time, or nil if task has no valid date_closed
|
|
375
|
+
def parse_closed_at(task)
|
|
376
|
+
# date_closed "0" or "" means reopened/not-closed; treat as nil.
|
|
377
|
+
ms = task['date_closed'].to_i
|
|
378
|
+
ms.positive? ? Time.at(ms / MS_PER_SECOND) : nil
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# @param task [Hash] ClickUp task hash
|
|
382
|
+
# @param time_spent_seconds [Integer, nil] calculated time in seconds
|
|
383
|
+
# @param time_source [Symbol, nil] how time was determined (:worklog, :calculated)
|
|
384
|
+
# @param closed_at [Time, nil] pre-parsed close time; avoids re-parsing date_closed
|
|
385
|
+
# when caller already computed it (e.g. apply_status_time_fallback)
|
|
386
|
+
def build_task_work_item(task, time_spent_seconds:, time_source:, closed_at: nil)
|
|
387
|
+
build_work_item(
|
|
388
|
+
external_id: task['id'],
|
|
389
|
+
title: task['name'],
|
|
390
|
+
completed_at: closed_at || parse_closed_at(task),
|
|
391
|
+
time_spent_seconds: time_spent_seconds,
|
|
392
|
+
time_source: time_source,
|
|
393
|
+
url: task['url'],
|
|
394
|
+
metadata: task_metadata(task)
|
|
395
|
+
)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Fetches all spaces for the configured team from the ClickUp API.
|
|
399
|
+
# Result is cached per-instance to avoid duplicate API calls within a single run
|
|
400
|
+
# (e.g. resolve_space_ids at init and fetch_space_statuses during fetch).
|
|
401
|
+
# Raises on network or API errors — callers are responsible for rescue.
|
|
402
|
+
#
|
|
403
|
+
# @return [Array<Hash>] raw space hashes from the ClickUp API
|
|
404
|
+
def fetch_spaces
|
|
405
|
+
@fetch_spaces ||= begin
|
|
406
|
+
response = connection.get("team/#{@team_id}/space", { archived: false })
|
|
407
|
+
response.body['spaces'] || []
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Resolves an array of space names to ClickUp space IDs via a single API call.
|
|
412
|
+
# Warns and skips any name that does not match a known space (case-sensitive).
|
|
413
|
+
#
|
|
414
|
+
# @param space_names [Array<String>] display names from config
|
|
415
|
+
# @return [Array<String>] resolved space IDs; may be shorter than space_names
|
|
416
|
+
def resolve_space_ids(space_names)
|
|
417
|
+
spaces = fetch_spaces
|
|
418
|
+
name_to_id = spaces.each_with_object({}) do |s, h|
|
|
419
|
+
validated_id = Validators.validate_id!(s['id'], name: 'space_id')
|
|
420
|
+
h[s['name']] = validated_id
|
|
421
|
+
rescue Validators::ValidationError => e
|
|
422
|
+
warn "[clickup] Warning: skipping space with invalid ID (#{e.message})"
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
space_names.filter_map do |name|
|
|
426
|
+
id = name_to_id[name]
|
|
427
|
+
warn "[clickup] Warning: unknown space '#{name}' ignored" unless id
|
|
428
|
+
id
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Fetches and categorizes statuses from ClickUp spaces by language-independent type:
|
|
433
|
+
# 'closed' → done, 'custom' → in_progress, 'open' → open.
|
|
434
|
+
# When @space_ids is non-empty, only statuses from those spaces are included.
|
|
435
|
+
#
|
|
436
|
+
# @return [Hash] categorized statuses or error
|
|
437
|
+
def fetch_space_statuses
|
|
438
|
+
spaces = fetch_spaces
|
|
439
|
+
spaces = spaces.select { |s| @space_ids.include?(s['id']) } if @space_ids.any?
|
|
440
|
+
|
|
441
|
+
all_statuses = spaces.flat_map { |space| space['statuses'] || [] }
|
|
442
|
+
|
|
443
|
+
# Single-pass categorization for performance (O(n) instead of O(4n))
|
|
444
|
+
categorized = { done: Set.new, in_progress: Set.new, open: Set.new, all: [] }
|
|
445
|
+
all_statuses.each do |s|
|
|
446
|
+
status_name = s['status']
|
|
447
|
+
categorized[:all] << { name: status_name, type: s['type'], color: s['color'] }
|
|
448
|
+
|
|
449
|
+
case s['type']
|
|
450
|
+
when 'closed' then categorized[:done] << status_name
|
|
451
|
+
when 'custom' then categorized[:in_progress] << status_name
|
|
452
|
+
when 'open' then categorized[:open] << status_name
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
{
|
|
457
|
+
done_statuses: categorized[:done].to_a,
|
|
458
|
+
in_progress_statuses: categorized[:in_progress].to_a,
|
|
459
|
+
open_statuses: categorized[:open].to_a,
|
|
460
|
+
all_statuses: categorized[:all].uniq
|
|
461
|
+
}
|
|
462
|
+
rescue StandardError => e
|
|
463
|
+
{ error: "Failed to fetch ClickUp statuses: #{e.message}" }
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module Snitcher
|
|
6
|
+
module Sources
|
|
7
|
+
class Git < Source
|
|
8
|
+
# Subprocess transport for the Git source.
|
|
9
|
+
#
|
|
10
|
+
# Extracted from Git for the same reason Jira::Client was (jira/client.rb): the
|
|
11
|
+
# source stays about interpreting commits, and the part that spawns processes can
|
|
12
|
+
# be tested against real ones.
|
|
13
|
+
#
|
|
14
|
+
# Two rules, and both exist because the alternative is a report that lies:
|
|
15
|
+
#
|
|
16
|
+
# 1. A git that exits non-zero raises. Returning an empty string instead would make
|
|
17
|
+
# "nobody committed this week" and "the checkout is broken" produce the same
|
|
18
|
+
# report — zero items, nothing in Orchestrator#failed_sources, a silent console.
|
|
19
|
+
# Callers that can genuinely absorb a failure ask for it by name via
|
|
20
|
+
# #output_or_nil.
|
|
21
|
+
# 2. A git that hangs is killed. Every command run here is a local read; the ones
|
|
22
|
+
# that do not return in milliseconds (an index.lock held by a crashed process, an
|
|
23
|
+
# unresponsive network filesystem, a credential helper prompting a terminal that
|
|
24
|
+
# is not there) do not return at all, and one of them is enough to wedge a whole
|
|
25
|
+
# batch of reports.
|
|
26
|
+
#
|
|
27
|
+
# @api private
|
|
28
|
+
class CommandRunner
|
|
29
|
+
# A git command that exited non-zero.
|
|
30
|
+
class CommandError < Error; end
|
|
31
|
+
|
|
32
|
+
# A git command that overran the timeout and was killed. Separate from
|
|
33
|
+
# CommandError so #output_or_nil can absorb the one without absorbing the other.
|
|
34
|
+
class TimeoutError < Error; end
|
|
35
|
+
|
|
36
|
+
# Seconds a single git command may run before it is killed. Generous enough for a
|
|
37
|
+
# `git log` over a large history on a cold cache, short enough that a hung command
|
|
38
|
+
# costs a report rather than the day. Override with sources.git.timeout.
|
|
39
|
+
DEFAULT_TIMEOUT_SECONDS = 30
|
|
40
|
+
|
|
41
|
+
# How long a killed git gets between the TERM and the KILL. TERM first so it can
|
|
42
|
+
# drop its index.lock; KILL because the point of a timeout is that it ends.
|
|
43
|
+
TERMINATE_GRACE_SECONDS = 2
|
|
44
|
+
|
|
45
|
+
# How much of git's complaint is quoted in an error message. The cause is in the
|
|
46
|
+
# first line or two; the rest is advice for a human at a terminal.
|
|
47
|
+
MAX_STDERR_LINES = 3
|
|
48
|
+
MAX_STDERR_CHARS = 300
|
|
49
|
+
|
|
50
|
+
# @param timeout_seconds [Numeric, String, nil] raw sources.git.timeout value
|
|
51
|
+
def initialize(repo_path:, timeout_seconds: nil)
|
|
52
|
+
@repo_path = repo_path
|
|
53
|
+
@timeout_seconds = normalize_timeout(timeout_seconds)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Runs a git command and returns its stdout.
|
|
57
|
+
#
|
|
58
|
+
# @param args [Array<String>] arguments after the `git` program name
|
|
59
|
+
# @return [String] stdout
|
|
60
|
+
# @raise [CommandError] git exited non-zero
|
|
61
|
+
# @raise [TimeoutError] git had to be killed
|
|
62
|
+
def output(args)
|
|
63
|
+
cmd = ['git', *args]
|
|
64
|
+
stdout, stderr, status = run(cmd)
|
|
65
|
+
return stdout if status.success?
|
|
66
|
+
|
|
67
|
+
raise CommandError, failure_message(cmd: cmd, status: status, stderr: stderr)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Same, for commands whose non-zero exit is an expected answer rather than an
|
|
71
|
+
# outage. Warns on stderr — degrading quietly is the bug this class exists to
|
|
72
|
+
# prevent — and returns nil. A timeout is never an expected answer, so it is left
|
|
73
|
+
# to propagate and fail the source.
|
|
74
|
+
#
|
|
75
|
+
# @param consequence [String] what the report loses, for the operator reading stderr
|
|
76
|
+
# @return [String, nil] stdout, or nil when git exited non-zero
|
|
77
|
+
def output_or_nil(args, consequence:)
|
|
78
|
+
output(args)
|
|
79
|
+
rescue CommandError => e
|
|
80
|
+
warn "[git] #{e.message}"
|
|
81
|
+
warn " #{consequence}"
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Runs a git command for its exit status alone, for the questions that have a
|
|
86
|
+
# non-zero answer rather than a failure ("does this checkout have any commits
|
|
87
|
+
# yet?"). A timeout still raises — that is not an answer either way.
|
|
88
|
+
#
|
|
89
|
+
# @return [Boolean] true when git exited zero
|
|
90
|
+
def success?(args)
|
|
91
|
+
_stdout, _stderr, status = run(['git', *args])
|
|
92
|
+
status.success?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# Accepts a number or a numeric string from sources.git.timeout. Anything else —
|
|
98
|
+
# a typo, a zero, a negative — falls back to the default instead of turning every
|
|
99
|
+
# git call into an instant kill.
|
|
100
|
+
def normalize_timeout(value)
|
|
101
|
+
seconds = Float(value, exception: false)
|
|
102
|
+
seconds&.positive? ? seconds : DEFAULT_TIMEOUT_SECONDS
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Spawns a command in the repo and returns [stdout, stderr, status].
|
|
106
|
+
#
|
|
107
|
+
# Open3.capture2 is not used here, and neither would Timeout.timeout around it
|
|
108
|
+
# help: capture2 closes the pipes and then joins the waiter thread in an ensure
|
|
109
|
+
# block, so a child that ignores the closed pipe leaves that join hanging with the
|
|
110
|
+
# timeout exception already in flight — the hang this method exists to end.
|
|
111
|
+
# Driving popen3 directly is what makes the kill below possible.
|
|
112
|
+
def run(cmd)
|
|
113
|
+
# pgroup: the child leads its own process group, so #terminate can signal the
|
|
114
|
+
# whole tree. git delegates to helpers — a credential helper, an alias running a
|
|
115
|
+
# shell — and signalling the git process alone would leave those behind, still
|
|
116
|
+
# holding the pipes this method is waiting on.
|
|
117
|
+
Open3.popen3(*cmd, chdir: @repo_path, pgroup: true) do |stdin, stdout, stderr, wait_thr|
|
|
118
|
+
stdin.close
|
|
119
|
+
# Drained on their own threads: a command that fills the stderr pipe while we
|
|
120
|
+
# block on stdout deadlocks, and both have to keep draining while we wait.
|
|
121
|
+
out_reader = Thread.new { stdout.read }
|
|
122
|
+
err_reader = Thread.new { stderr.read }
|
|
123
|
+
|
|
124
|
+
unless wait_thr.join(@timeout_seconds)
|
|
125
|
+
terminate(wait_thr)
|
|
126
|
+
abandon(out_reader, err_reader)
|
|
127
|
+
raise TimeoutError, timeout_message(cmd)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Read after the wait, not before: it is the exiting process closing its end
|
|
131
|
+
# of the pipes that lets these two threads finish.
|
|
132
|
+
[out_reader.value, err_reader.value, wait_thr.value]
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Ends the process group of a command that overran the timeout. Errno::ESRCH means
|
|
137
|
+
# it exited on its own between the join giving up and the signal landing — a race
|
|
138
|
+
# we win by ignoring, since that exit is what the signal was asking for.
|
|
139
|
+
def terminate(wait_thr)
|
|
140
|
+
Process.kill('TERM', -wait_thr.pid)
|
|
141
|
+
return if wait_thr.join(TERMINATE_GRACE_SECONDS)
|
|
142
|
+
|
|
143
|
+
Process.kill('KILL', -wait_thr.pid)
|
|
144
|
+
rescue Errno::ESRCH
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The output is discarded on the timeout path, so the drain threads get a deadline
|
|
149
|
+
# rather than the unbounded read they are in the middle of: anything that survived
|
|
150
|
+
# the KILL can still be holding the write end of the pipe open.
|
|
151
|
+
def abandon(*readers)
|
|
152
|
+
readers.each { |reader| reader.kill unless reader.join(TERMINATE_GRACE_SECONDS) }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def timeout_message(cmd)
|
|
156
|
+
scrub_credentials("#{describe(cmd)} did not finish within #{@timeout_seconds}s " \
|
|
157
|
+
"in #{@repo_path} and was killed")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def failure_message(cmd:, status:, stderr:)
|
|
161
|
+
message = "#{describe(cmd)} failed in #{@repo_path} (#{exit_description(status)})"
|
|
162
|
+
detail = stderr_summary(stderr)
|
|
163
|
+
scrub_credentials(detail.empty? ? message : "#{message}: #{detail}")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def exit_description(status)
|
|
167
|
+
return "exit status #{status.exitstatus}" if status.exitstatus
|
|
168
|
+
|
|
169
|
+
"terminated by signal #{status.termsig}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# These messages reach a report through Orchestrator#failed_sources. Arguments are
|
|
173
|
+
# built from refs and format strings rather than typed by hand, so anything
|
|
174
|
+
# unprintable in one is escaped rather than smuggled into HTML and Markdown output.
|
|
175
|
+
def describe(cmd)
|
|
176
|
+
cmd.join(' ').gsub(/[^[:print:]]/) { |char| format('\\x%02X', char.ord) }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def stderr_summary(stderr)
|
|
180
|
+
summary = stderr.to_s.strip.lines.first(MAX_STDERR_LINES)
|
|
181
|
+
.map(&:strip).reject(&:empty?).join('; ')
|
|
182
|
+
return summary unless summary.length > MAX_STDERR_CHARS
|
|
183
|
+
|
|
184
|
+
"#{summary[0, MAX_STDERR_CHARS]}…"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# A remote URL can carry a token (https://ghp_xxx@github.com/...), and it reaches
|
|
188
|
+
# these messages from both sides: git echoes the URL in its errors, and a command
|
|
189
|
+
# can take one as an argument. The messages travel further than the console —
|
|
190
|
+
# Orchestrator#failed_sources puts them in the report — so they get the same
|
|
191
|
+
# credential stripping Git#normalize_remote_url applies to the URL itself.
|
|
192
|
+
def scrub_credentials(message)
|
|
193
|
+
message.gsub(%r{://[^\s/]*@}, '://')
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|