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