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