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,523 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'time'
5
+ require_relative '../jql_validator'
6
+
7
+ module Snitcher
8
+ module Sources
9
+ # JIRA Source Fetcher — Jira Cloud REST API v3
10
+ #
11
+ # SECURITY NOTES:
12
+ # - The JIRA REST API does not support parameterized JQL queries
13
+ # - All JQL is constructed as strings with validated/escaped user input
14
+ # - Email addresses are validated by Validators.validate_email! (RFC 5322 regex)
15
+ # - Additional JQL-specific validation in JQLValidator prevents dangerous patterns
16
+ # - Dates are constructed by our code (not user input) but validated for format
17
+ # - Issue keys are validated before interpolation into URL paths
18
+ #
19
+ # ARCHITECTURE NOTES:
20
+ # - Jira Cloud only: /rest/api/3 with Basic auth. Data Center (/rest/api/2 + Bearer
21
+ # PAT) is not supported.
22
+ # - Supports multi-language status names via statusCategory (not hardcoded status names)
23
+ # - Changelog time calculation uses statusCategory 'indeterminate' (In Progress equivalent)
24
+ # which is language-independent, unlike status names ("In Arbeit", "En cours", etc.).
25
+ # The changelog itself carries only status IDs and localised names, so IDs are resolved
26
+ # through StatusCategories.
27
+ # - N+1 fetching: Jira has no public batch worklog endpoint (/rest/internal/api/
28
+ # latest/worklog/bulk is internal and ASAP-authenticated), so worklogs stay at one
29
+ # call per ticket. Changelogs for tickets without worklogs are batched through
30
+ # POST /rest/api/3/issue/bulkfetch, 100 issues per call.
31
+ # - /rest/api/3/changelog/bulkfetch is not used: it filters by field and paginates the
32
+ # changelogs itself, but it returns 404 on instances where /issue/bulkfetch with the
33
+ # same body works, so the batching goes through the issue endpoint and pages long
34
+ # changelogs itself:
35
+ # https://community.developer.atlassian.com/t/bulk-fetch-changelogs-api-returns-404/87255
36
+ # https://community.developer.atlassian.com/t/bulk-fetch-changelogs-experimental-api/87240
37
+ #
38
+ # rubocop:disable-next Metrics/ClassLength -- Length due to required YARD docs and helper methods
39
+ class Jira < Source
40
+ RESOLVED_CATEGORIES = %w[Done].freeze
41
+ # JIRA statusCategory IDs are language-independent (unlike status names)
42
+ # 'indeterminate' = "In Progress" category across all JIRA languages
43
+ IN_PROGRESS_CATEGORY_ID = 'indeterminate'
44
+
45
+ # Valid JIRA project key pattern — prevents JQL injection
46
+ PROJECT_KEY_PATTERN = /\A[A-Z][A-Z0-9_]+\z/
47
+
48
+ # Fields requested from /search/jql. `key` is always returned at the top level
49
+ # and is not a field id.
50
+ SEARCH_FIELDS = %w[summary status assignee resolutiondate].freeze
51
+
52
+ # Status discovery deliberately samples one page rather than paginating —
53
+ # see #fetch_statuses_from_issues.
54
+ DISCOVERY_MAX_PAGES = 1
55
+
56
+ def initialize(config = {})
57
+ super
58
+ @jira_url = (Source.presence(config[:jira_url]) || Source.env('JIRA_URL'))&.chomp('/')
59
+
60
+ # Configured status name -> category mappings (from .snitcher.yml)
61
+ @in_progress_names = (config[:in_progress_statuses] || []).map(&:downcase)
62
+ @done_names = (config[:done_statuses] || []).map(&:downcase)
63
+ @todo_names = (config[:todo_statuses] || []).map(&:downcase)
64
+
65
+ # JIRA project keys to filter by (from .snitcher.yml)
66
+ @project_keys = validate_project_keys(config[:projects] || [])
67
+
68
+ # Seam for the test suite, which stubs retryable statuses and would otherwise
69
+ # sleep through three real backoffs per test. Not part of .snitcher.yml.
70
+ @retry_interval = config.fetch(:retry_interval, Client::RETRY_INTERVAL)
71
+ end
72
+
73
+ def source_name
74
+ :jira
75
+ end
76
+
77
+ # Note the cast: without it the && chain returns the API token itself, which
78
+ # then leaks into anything that logs or inspects the result of this predicate.
79
+ def available?
80
+ !!(@jira_url && jira_username && jira_api_token)
81
+ end
82
+
83
+ def unavailable_reason
84
+ missing = []
85
+ missing << 'JIRA_URL' if @jira_url.nil?
86
+ missing << 'JIRA_USERNAME' if jira_username.nil?
87
+ missing << 'JIRA_API_TOKEN' if jira_api_token.nil?
88
+ "#{missing.join(', ')} #{missing.one? ? 'is' : 'are'} not set"
89
+ end
90
+
91
+ # Discover all statuses in use across JIRA projects.
92
+ # Samples issues through #fetch_statuses_from_issues and extracts
93
+ # status name -> statusCategory mappings.
94
+ #
95
+ # @param config [Hash] optional config overrides
96
+ # @return [Hash] status discovery result
97
+ def self.discover_statuses(config = {})
98
+ instance = new(config)
99
+ return { error: 'JIRA not configured' } unless instance.available?
100
+
101
+ instance.send(:fetch_jira_statuses)
102
+ end
103
+
104
+ # Discover all available JIRA projects for the configured instance.
105
+ #
106
+ # @param config [Hash] optional config overrides
107
+ # @return [Array<Hash>, Hash] array of {key:, name:} or {error:} hash
108
+ def self.discover_projects(config = {})
109
+ instance = new(config)
110
+ return { error: 'JIRA not configured' } unless instance.available?
111
+
112
+ instance.send(:fetch_jira_projects)
113
+ end
114
+
115
+ def fetch(user_email:, since:, until_time:)
116
+ return [] unless available?
117
+
118
+ validated_email = Validators.validate_email!(primary_email(user_email))
119
+ tickets = find_resolved_tickets(validated_email, since: since, until_time: until_time)
120
+ results = []
121
+ no_worklog_keys = []
122
+
123
+ tickets.each do |ticket|
124
+ key = ticket['key']
125
+ jira_display_name = ticket.dig('fields', 'assignee', 'displayName')
126
+ worklogs = fetch_worklogs(key)
127
+ user_seconds = sum_user_worklogs(worklogs, validated_email,
128
+ jira_display_name: jira_display_name)
129
+
130
+ if user_seconds.positive?
131
+ results << build_jira_work_item(ticket, user_seconds, :worklog)
132
+ else
133
+ no_worklog_keys << key
134
+ end
135
+ end
136
+
137
+ apply_changelog_fallback(results, no_worklog_keys, tickets)
138
+ results
139
+ end
140
+
141
+ private
142
+
143
+ # Validates and uppercases project keys to prevent JQL injection.
144
+ # @param keys [Array<String>] raw project key strings
145
+ # @return [Array<String>] validated, uppercased project keys
146
+ def validate_project_keys(keys)
147
+ keys.map do |key|
148
+ upcased = key.to_s.upcase
149
+ unless upcased.match?(PROJECT_KEY_PATTERN)
150
+ warn "[jira] Warning: invalid project key '#{key}' ignored (must match #{PROJECT_KEY_PATTERN.source})"
151
+ next nil
152
+ end
153
+ upcased
154
+ end.compact
155
+ end
156
+
157
+ # Builds JQL project filter clause from configured project keys.
158
+ # @return [String] "project IN (...) AND " or empty string
159
+ def project_clause
160
+ return '' if @project_keys.empty?
161
+
162
+ keys = @project_keys.map { |k| %("#{k}") }.join(', ')
163
+ "project IN (#{keys}) AND "
164
+ end
165
+
166
+ def client
167
+ @client ||= Client.new(base_url: @jira_url, username: jira_username,
168
+ api_token: jira_api_token, retry_interval: @retry_interval)
169
+ end
170
+
171
+ def status_categories
172
+ @status_categories ||= StatusCategories.new(client)
173
+ end
174
+
175
+ def jira_username
176
+ Source.env('JIRA_USERNAME')
177
+ end
178
+
179
+ def jira_api_token
180
+ Source.env('JIRA_API_TOKEN')
181
+ end
182
+
183
+ # Finds resolved tickets for a user within a date range.
184
+ #
185
+ # SEMANTIC NOTE: This query uses statusCategory="Done" without requiring that tickets
186
+ # passed through "In Progress". This is intentional - tickets resolved directly from
187
+ # backlog (e.g., duplicates, won't-fix) are included, and time is calculated from
188
+ # worklogs or changelog fallback. The old `status WAS "In Progress"` filter was removed
189
+ # to support multi-language JIRA instances where status names vary.
190
+ #
191
+ # @param user_email [String] validated user email
192
+ # @param since [Time] start of date range
193
+ # @param until_time [Time] end of date range
194
+ # @return [Array<Hash>] array of ticket hashes
195
+ def find_resolved_tickets(user_email, since:, until_time:)
196
+ JQLValidator.validate_email_for_jql!(user_email)
197
+ since_str = JQLValidator.validate_date_format!(since.strftime('%Y-%m-%d'))
198
+ until_str = JQLValidator.validate_date_format!(until_time.strftime('%Y-%m-%d'))
199
+
200
+ status_category = RESOLVED_CATEGORIES.first
201
+ jql = <<~JQL.strip
202
+ #{project_clause}assignee = "#{user_email}" AND statusCategory = "#{status_category}"
203
+ AND resolved >= "#{since_str}" AND resolved <= "#{until_str}"
204
+ ORDER BY resolved DESC
205
+ JQL
206
+
207
+ client.search(jql: jql, fields: SEARCH_FIELDS)
208
+ end
209
+
210
+ # Validators::ValidationError is caught alongside Faraday::Error on purpose. The
211
+ # key comes from Jira, not from the user, and Orchestrator#fetch re-raises
212
+ # ValidationError to fail fast on bad input — which would discard the Git, Linear,
213
+ # ClickUp and Calendar results too. Remote data that fails its guard belongs with
214
+ # the network errors next to it: warn, and skip the one ticket.
215
+ def fetch_worklogs(issue_key)
216
+ client.worklogs(issue_key)
217
+ rescue Faraday::Error, Validators::ValidationError => e
218
+ warn "[jira] Warning: worklog fetch failed for #{issue_key} (#{e.message})"
219
+ []
220
+ end
221
+
222
+ # Sums worklog time for a specific user.
223
+ #
224
+ # @param worklogs [Array<Hash>] array of worklog entries
225
+ # @param user_email [String] the user's email to match
226
+ # @return [Integer] total seconds logged by the user
227
+ def sum_user_worklogs(worklogs, user_email, jira_display_name: nil)
228
+ # Precompute matching data once (not per-worklog) for performance
229
+ match_data = build_author_match_data(user_email, jira_display_name: jira_display_name)
230
+ worklogs
231
+ .select { |w| worklog_author_matches?(author_identity(w['author']), match_data) }
232
+ .sum { |w| w['timeSpentSeconds'].to_i }
233
+ end
234
+
235
+ # Reduces a Jira worklog author to the single string #worklog_author_matches?
236
+ # works on. emailAddress is absent when the account's privacy settings hide it
237
+ # (GDPR), in which case the display-name heuristic takes over.
238
+ #
239
+ # This is the seam where exact accountId matching would attach later; the
240
+ # heuristic would stay as the fallback for hidden emails.
241
+ #
242
+ # @param author [Hash, String, nil] REST returns a Hash; a String is tolerated
243
+ # @return [String, nil]
244
+ def author_identity(author)
245
+ return author unless author.is_a?(Hash)
246
+
247
+ author['emailAddress'] || author['displayName']
248
+ end
249
+
250
+ # Builds precomputed data for author matching.
251
+ # Called once per user, not per worklog.
252
+ #
253
+ # @param user_email [String] the user's email
254
+ # @param jira_display_name [String, nil] the user's Jira display name from the assignee field
255
+ # @return [Hash] match data with :email, :username, :name_parts, :display_name keys
256
+ def build_author_match_data(user_email, jira_display_name: nil)
257
+ email_lower = user_email.downcase
258
+ # Extract username, stripping any +tag suffix (e.g., john.smith+tag@example.com -> john.smith)
259
+ username = email_lower.split('@').first.gsub(/\+.*/, '')
260
+ # Split on dots to get name parts, capitalize for display name matching
261
+ # e.g., "john.smith" -> ["John", "Smith"]
262
+ # Filter out parts shorter than 2 chars to avoid overly broad matches (e.g., "S" matching too many)
263
+ name_parts = username.split('.').map(&:capitalize).select { |p| p.length >= 2 }
264
+
265
+ { email: email_lower, username: username, name_parts: name_parts,
266
+ display_name: jira_display_name&.downcase }
267
+ end
268
+
269
+ # Checks if a worklog author matches the target user.
270
+ #
271
+ # Matching strategy (in order of precedence):
272
+ # 0. Exact Jira display name match (from ticket assignee field)
273
+ # 1. Exact email match (case-insensitive)
274
+ # 2. Exact username match (case-insensitive)
275
+ # 3. Display name contains all name parts (case-insensitive)
276
+ # e.g., "John Smith" matches user "john.smith@example.com"
277
+ #
278
+ # @param author [String, nil] the worklog author identifier (email, username, or display name)
279
+ # @param match_data [Hash] precomputed match data from build_author_match_data
280
+ # @return [Boolean] true if author matches the target user
281
+ # @note Display name matching is heuristic and may have false positives for common names
282
+ def worklog_author_matches?(author, match_data)
283
+ return false unless author
284
+
285
+ author_lower = author.downcase
286
+
287
+ # Strategy 0: Exact Jira display name match (e.g. "Arek" from ticket assignee)
288
+ return true if match_data[:display_name] && author_lower == match_data[:display_name]
289
+
290
+ # Strategy 1: Exact email match
291
+ return true if author_lower == match_data[:email]
292
+
293
+ # Strategy 2: Exact username match
294
+ return true if author_lower == match_data[:username]
295
+
296
+ # Strategy 3: Display name contains all name parts (case-insensitive)
297
+ # Each name part must appear somewhere in the author string
298
+ match_data[:name_parts].all? { |part| author_lower.include?(part.downcase) }
299
+ end
300
+
301
+ # Fills in time for tickets that carried no worklogs, from their status history.
302
+ #
303
+ # The changelogs are fetched in bulk before the loop and joined by key, the shape
304
+ # ClickUp#apply_status_time_fallback uses — one call per 100 tickets instead of one
305
+ # per ticket. A ticket missing from the response yields no time rather than being
306
+ # dropped, so a partial bulk response still produces a complete ticket list.
307
+ def apply_changelog_fallback(results, no_worklog_keys, tickets)
308
+ return if no_worklog_keys.empty?
309
+
310
+ tickets_by_key = tickets.to_h { |t| [t['key'], t] }
311
+ changelogs_by_key = fetch_changelogs(no_worklog_keys)
312
+
313
+ no_worklog_keys.each do |key|
314
+ ticket = tickets_by_key[key]
315
+ next unless ticket
316
+
317
+ seconds = calculate_time_from_changelog(changelogs_by_key[key])
318
+ results << build_jira_work_item(ticket, seconds, :calculated)
319
+ end
320
+ end
321
+
322
+ # No rescue here on purpose. Client#changelogs already warns past the failures a
323
+ # report can absorb — a rejected key, a failed slice, a single unreadable ticket —
324
+ # and returns what it did read. What reaches this method is a run in which no
325
+ # changelog at all could be fetched, and swallowing that would report every ticket
326
+ # in the run as zero hours while the report still called itself complete. Raising
327
+ # puts the source in Orchestrator#failed_sources instead.
328
+ #
329
+ # @param issue_keys [Array<String>] keys of the tickets that had no worklogs
330
+ # @return [Hash{String => Array<Hash>}] issue key => changelog histories
331
+ def fetch_changelogs(issue_keys)
332
+ client.changelogs(issue_keys)
333
+ end
334
+
335
+ # Calculates time spent in "In Progress" status from changelog entries.
336
+ #
337
+ # Uses statusCategory key ('indeterminate') instead of status name to support
338
+ # multi-language JIRA instances where status names vary (e.g., "In Arbeit",
339
+ # "En cours", "In Progress").
340
+ #
341
+ # @param changelogs [Array<Hash>, nil] changelog history entries
342
+ # @return [Integer] total seconds spent in "In Progress" statusCategory
343
+ def calculate_time_from_changelog(changelogs)
344
+ return 0 if changelogs.nil? || changelogs.empty?
345
+
346
+ entries = changelogs.sort_by { |c| Time.parse(c['created']) }
347
+ total_seconds = 0
348
+ in_progress_start = nil
349
+
350
+ entries.each do |entry|
351
+ timestamp = Time.parse(entry['created'])
352
+ status_change = (entry['items'] || []).find { |i| i['field'] == 'status' }
353
+ next unless status_change
354
+
355
+ # Use statusCategory key (language-independent) if available,
356
+ # fall back to status name for backward compatibility
357
+ to_category = extract_status_category(status_change)
358
+
359
+ if to_category == IN_PROGRESS_CATEGORY_ID
360
+ in_progress_start = timestamp
361
+ elsif in_progress_start
362
+ total_seconds += WorkingHoursCalculator.seconds_between(in_progress_start, timestamp, @working_hours)
363
+ in_progress_start = nil
364
+ end
365
+ end
366
+ total_seconds
367
+ end
368
+
369
+ # Extracts the language-independent statusCategory key for a status change.
370
+ #
371
+ # The changelog gives only IDs and localised names: `to` is the numeric status ID,
372
+ # `toString` its display name.
373
+ #
374
+ # Precedence, highest first:
375
+ # 1. names configured in .snitcher.yml — an explicit decision by the operator,
376
+ # and the only path that existed before the catalogue was introduced, so an
377
+ # org that classified "In Review" as in-progress keeps its reported hours;
378
+ # 2. the instance's status catalogue — authoritative and language-independent;
379
+ # 3. hardcoded English names — a guess, for statuses the catalogue does not know
380
+ # and for the case where it could not be fetched at all.
381
+ #
382
+ # @param status_change [Hash] the changelog item for a status change
383
+ # @return [String, nil] 'new', 'indeterminate', 'done', or nil
384
+ def extract_status_category(status_change)
385
+ name = status_change['toString']
386
+
387
+ map_configured_status_name(name) ||
388
+ status_categories.category_for(status_change['to']) ||
389
+ map_english_status_name(name)
390
+ end
391
+
392
+ # Maps a status name to a category key using the names configured in
393
+ # .snitcher.yml (sources.jira.in_progress_statuses and friends).
394
+ #
395
+ # @param status_name [String, nil] the status name
396
+ # @return [String, nil] the category key or nil
397
+ def map_configured_status_name(status_name)
398
+ return nil unless status_name
399
+
400
+ name = status_name.downcase
401
+
402
+ return IN_PROGRESS_CATEGORY_ID if @in_progress_names.include?(name)
403
+ return 'done' if @done_names.include?(name)
404
+ return 'new' if @todo_names.include?(name)
405
+
406
+ nil
407
+ end
408
+
409
+ # Maps hardcoded English status names to category keys — the last resort, for
410
+ # statuses the catalogue does not carry and for a catalogue that could not be
411
+ # fetched. Only correct for English instances, which is why it ranks below both
412
+ # the configured names and the catalogue.
413
+ #
414
+ # @param status_name [String, nil] the status name
415
+ # @return [String, nil] the category key or nil
416
+ def map_english_status_name(status_name)
417
+ return nil unless status_name
418
+
419
+ case status_name.downcase
420
+ when 'in progress', 'in development', 'in review'
421
+ IN_PROGRESS_CATEGORY_ID
422
+ when 'done', 'closed', 'resolved'
423
+ 'done'
424
+ when 'to do', 'open', 'backlog'
425
+ 'new'
426
+ end
427
+ end
428
+
429
+ # Fetches and categorizes JIRA statuses.
430
+ # @return [Hash] categorized statuses or error
431
+ def fetch_jira_statuses
432
+ categorize_statuses(fetch_statuses_from_issues)
433
+ rescue StandardError => e
434
+ { error: "Failed to fetch JIRA statuses: #{e.message}" }
435
+ end
436
+
437
+ # Fetches all available JIRA projects.
438
+ # @return [Array<Hash>, Hash] array of {key:, name:} or {error:} hash
439
+ def fetch_jira_projects
440
+ client.projects.map { |p| { key: p['key'], name: p['name'] } }
441
+ rescue StandardError => e
442
+ { error: "Failed to fetch JIRA projects: #{e.message}" }
443
+ end
444
+
445
+ # Samples statuses from the last 100 updated issues.
446
+ #
447
+ # NOTE: This is a sampling approach, not exhaustive discovery — it is capped at one
448
+ # page (DISCOVERY_MAX_PAGES) on purpose. Enumerating /rest/api/3/status would return
449
+ # every status on the instance but would lose the sources.jira.projects scoping that
450
+ # this JQL applies, so sampling stays.
451
+ #
452
+ # @return [Array<Hash>] unique statuses with name and category_key
453
+ def fetch_statuses_from_issues
454
+ issues = client.search(jql: "#{project_clause}updated >= -365d ORDER BY updated DESC",
455
+ fields: %w[status], max_pages: DISCOVERY_MAX_PAGES)
456
+
457
+ status_map = {}
458
+ issues.each do |issue|
459
+ status = issue.dig('fields', 'status')
460
+ next unless status
461
+
462
+ name = status['name']
463
+ next unless name
464
+
465
+ # statusCategory.key is language-independent. A v3 status object always carries
466
+ # it (its colour lives at statusCategory.colorName, not on the status), so a
467
+ # missing key leaves the status uncategorised rather than guessing.
468
+ status_map[name] ||= { name: name, category_key: status.dig('statusCategory', 'key') }
469
+ end
470
+ status_map.values
471
+ end
472
+
473
+ # Categorizes statuses by their JIRA statusCategory key.
474
+ #
475
+ # JIRA statusCategory keys are language-independent:
476
+ # - 'done' -> done_statuses (completed work)
477
+ # - 'indeterminate' -> in_progress_statuses (active work)
478
+ # - 'new' -> todo_statuses (not started)
479
+ #
480
+ # @param statuses [Array<Hash>] statuses with :name and :category_key
481
+ # @return [Hash] categorized statuses
482
+ def categorize_statuses(statuses)
483
+ # Single-pass categorization for performance
484
+ categorized = { done: [], in_progress: [], todo: [] }
485
+ statuses.each do |s|
486
+ case s[:category_key]
487
+ when 'done' then categorized[:done] << s[:name]
488
+ when 'indeterminate' then categorized[:in_progress] << s[:name]
489
+ when 'new' then categorized[:todo] << s[:name]
490
+ end
491
+ end
492
+
493
+ {
494
+ done_statuses: categorized[:done],
495
+ in_progress_statuses: categorized[:in_progress],
496
+ todo_statuses: categorized[:todo],
497
+ all_statuses: statuses
498
+ }
499
+ end
500
+
501
+ # Builds a WorkItem from a JIRA ticket.
502
+ #
503
+ # @param ticket [Hash] the JIRA issue: 'key' at the top level, everything else under 'fields'
504
+ # @param time_seconds [Integer] time spent in seconds
505
+ # @param time_source [Symbol] source of time data (:worklog or :calculated)
506
+ # @return [WorkItem] the constructed work item
507
+ def build_jira_work_item(ticket, time_seconds, time_source)
508
+ fields = ticket['fields'] || {}
509
+ resolution_date = fields['resolutiondate']
510
+
511
+ build_work_item(
512
+ external_id: ticket['key'],
513
+ title: fields['summary'],
514
+ completed_at: resolution_date ? Time.parse(resolution_date) : nil,
515
+ time_spent_seconds: time_seconds.positive? ? time_seconds : nil,
516
+ time_source: time_seconds.positive? ? time_source : nil,
517
+ url: "#{@jira_url}/browse/#{ticket['key']}",
518
+ metadata: { status: fields.dig('status', 'name') }
519
+ )
520
+ end
521
+ end
522
+ end
523
+ end