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,346 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'faraday'
|
|
5
|
+
require 'faraday/retry'
|
|
6
|
+
require 'json'
|
|
7
|
+
|
|
8
|
+
module Snitcher
|
|
9
|
+
module Sources
|
|
10
|
+
class Jira < Source
|
|
11
|
+
# Jira Cloud REST API v3 transport.
|
|
12
|
+
#
|
|
13
|
+
# Extracted from Jira so the HTTP surface can be tested on its own and so the
|
|
14
|
+
# source stays focused on interpreting the data. Mirrors the Faraday setup used
|
|
15
|
+
# by Linear (linear.rb:73-83) and ClickUp (clickup.rb:145-154), with two additions:
|
|
16
|
+
# `raise_error` and a retry stack ordered to work alongside it.
|
|
17
|
+
#
|
|
18
|
+
# Without `raise_error` a 401 from an expired token returns a body that just does
|
|
19
|
+
# not match the expected shape, and the source reports "no work" instead of an
|
|
20
|
+
# outage — see Orchestrator#fetch, which records raising sources and marks the
|
|
21
|
+
# report incomplete. The same reasoning is why an unparsed body raises here
|
|
22
|
+
# instead of degrading to an empty Hash: a proxy interstitial or maintenance page
|
|
23
|
+
# served with a 200 is an outage, not an empty result set.
|
|
24
|
+
#
|
|
25
|
+
# What each caller does with those exceptions:
|
|
26
|
+
# - #search: nothing catches them, so a failure fails the source.
|
|
27
|
+
# - #worklogs: Jira#fetch_worklogs warns past a Faraday::Error or a rejected issue
|
|
28
|
+
# key and drops that one ticket's worklogs. A wrong-shape body is not caught —
|
|
29
|
+
# an interstitial answers every request, not one ticket, so it fails the source.
|
|
30
|
+
# - #changelogs: partial failures warn and cost the tickets they affect (see the
|
|
31
|
+
# method); a run where no changelog at all could be read raises.
|
|
32
|
+
#
|
|
33
|
+
# Middleware order matters: Faraday nests handlers in registration order, so
|
|
34
|
+
# `raise_error` has to be registered *before* `:retry` to end up outside it.
|
|
35
|
+
# Registered after, it converts a 429/5xx into a Faraday::Error before retry can
|
|
36
|
+
# inspect the status, and those exceptions are not in faraday-retry's default
|
|
37
|
+
# list — `retry_statuses` would then never fire. `methods` is widened past
|
|
38
|
+
# faraday-retry's idempotent-only default because /search/jql is a POST read.
|
|
39
|
+
#
|
|
40
|
+
# Cloud only. Data Center (/rest/api/2 + Bearer PAT) is not supported.
|
|
41
|
+
# @api private
|
|
42
|
+
class Client
|
|
43
|
+
API_PATH = '/rest/api/3'
|
|
44
|
+
PAGE_SIZE = 100
|
|
45
|
+
MAX_PAGES = 10 # Safety limit to prevent infinite loops
|
|
46
|
+
WORKLOG_PAGE_SIZE = 100
|
|
47
|
+
# Issues per POST /issue/bulkfetch call. The endpoint documents a higher ceiling
|
|
48
|
+
# for requests that name every field explicitly; `expand` requests do not
|
|
49
|
+
# qualify, so this stays at 100 rather than being raised to match the docs.
|
|
50
|
+
BULK_FETCH_SIZE = 100
|
|
51
|
+
RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
52
|
+
# /search/jql and /issue/bulkfetch are POST reads, so POST joins the retryable
|
|
53
|
+
# verbs. Jira exposes no POST write on the paths this client touches.
|
|
54
|
+
RETRY_METHODS = %i[get head options put delete post].freeze
|
|
55
|
+
RETRY_MAX = 3
|
|
56
|
+
RETRY_INTERVAL = 0.5
|
|
57
|
+
# Jira sends Retry-After on a 429 and its points quota is hourly, so the header
|
|
58
|
+
# can ask for minutes. faraday-retry defaults max_interval to Float::MAX and
|
|
59
|
+
# sleeps the header value verbatim; three retries of that inside the sequential
|
|
60
|
+
# per-ticket loop makes a throttled report indistinguishable from a hung one.
|
|
61
|
+
# Above this bound faraday-retry skips the retry, so the call fails fast and the
|
|
62
|
+
# source reaches Orchestrator#failed_sources instead of blocking.
|
|
63
|
+
RETRY_MAX_INTERVAL = 5.0
|
|
64
|
+
# Jitter, as a fraction of the interval: three retries at a flat interval from
|
|
65
|
+
# every ticket in a loop is a thundering herd against the per-second burst limit.
|
|
66
|
+
RETRY_RANDOMNESS = 0.5
|
|
67
|
+
|
|
68
|
+
# @param retry_interval [Float] seconds between retries. Lower it to keep a test
|
|
69
|
+
# suite from sleeping through the backoff; nothing in production passes it.
|
|
70
|
+
def initialize(base_url:, username:, api_token:, retry_interval: RETRY_INTERVAL)
|
|
71
|
+
@base_url = base_url
|
|
72
|
+
@username = username
|
|
73
|
+
@api_token = api_token
|
|
74
|
+
@retry_interval = retry_interval
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# POST /rest/api/3/search/jql
|
|
78
|
+
#
|
|
79
|
+
# The legacy /rest/api/3/search endpoint is withdrawn. Its replacement paginates
|
|
80
|
+
# by opaque token and returns no `total`, so the loop ends on a nil token.
|
|
81
|
+
#
|
|
82
|
+
# @param jql [String] the query
|
|
83
|
+
# @param fields [Array<String>] field ids to return
|
|
84
|
+
# @param max_pages [Integer] page cap; 1 makes the call a single-page sample
|
|
85
|
+
# @return [Array<Hash>] issues, each with 'key' at the top level and 'fields' nested
|
|
86
|
+
def search(jql:, fields:, max_pages: MAX_PAGES)
|
|
87
|
+
issues = []
|
|
88
|
+
token = nil
|
|
89
|
+
pages = 0
|
|
90
|
+
|
|
91
|
+
loop do
|
|
92
|
+
body = { jql: jql, fields: fields, maxResults: PAGE_SIZE }
|
|
93
|
+
body[:nextPageToken] = token if token
|
|
94
|
+
|
|
95
|
+
payload = post("#{API_PATH}/search/jql", body)
|
|
96
|
+
issues.concat(payload['issues'] || [])
|
|
97
|
+
|
|
98
|
+
token = payload['nextPageToken']
|
|
99
|
+
pages += 1
|
|
100
|
+
break if token.nil? || pages >= max_pages
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
issues
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# GET /rest/api/3/issue/{key}/worklog
|
|
107
|
+
# @return [Array<Hash>] worklog entries with 'author' (Hash) and 'timeSpentSeconds'
|
|
108
|
+
def worklogs(issue_key)
|
|
109
|
+
entries = []
|
|
110
|
+
start_at = 0
|
|
111
|
+
pages = 0
|
|
112
|
+
|
|
113
|
+
loop do
|
|
114
|
+
payload = get("#{API_PATH}/issue/#{issue_path_segment(issue_key)}/worklog",
|
|
115
|
+
startAt: start_at, maxResults: WORKLOG_PAGE_SIZE)
|
|
116
|
+
batch = payload['worklogs'] || []
|
|
117
|
+
entries.concat(batch)
|
|
118
|
+
|
|
119
|
+
start_at += batch.size
|
|
120
|
+
pages += 1
|
|
121
|
+
break if batch.empty? || start_at >= payload['total'].to_i || pages >= MAX_PAGES
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
entries
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# POST /rest/api/3/issue/bulkfetch — changelogs for many issues at once.
|
|
128
|
+
#
|
|
129
|
+
# Replaces one #changelog call per issue with ceil(n / BULK_FETCH_SIZE) calls.
|
|
130
|
+
# `expand: changelog` embeds the histories in each issue, and the embedded form
|
|
131
|
+
# is capped by the server, so an issue whose histories look truncated is re-read
|
|
132
|
+
# through the paginated #changelog.
|
|
133
|
+
#
|
|
134
|
+
# Failure is deliberately granular, because a batch call otherwise turns one
|
|
135
|
+
# error into every ticket in the run reporting no hours:
|
|
136
|
+
# - a key the path guard rejects costs only itself, not its slice;
|
|
137
|
+
# - a slice that fails falls back to one #changelog call per issue in it — the
|
|
138
|
+
# endpoint the batching replaced, which answers when bulkfetch does not;
|
|
139
|
+
# - an issue that fails on its own costs that ticket, and warns;
|
|
140
|
+
# - `issueErrors` in an otherwise-successful response warns per issue.
|
|
141
|
+
# Slices that already succeeded are kept in every case. Only a run that produced
|
|
142
|
+
# no changelog at all re-raises, so a changelog outage reaches Orchestrator#fetch
|
|
143
|
+
# and the report marks itself incomplete instead of quietly reporting zeroes.
|
|
144
|
+
#
|
|
145
|
+
# /changelog/bulkfetch would carry the pagination itself and filter by field, but
|
|
146
|
+
# it 404s on some instances — see the Jira ARCHITECTURE NOTES.
|
|
147
|
+
#
|
|
148
|
+
# @param issue_keys [Array<String>]
|
|
149
|
+
# @return [Hash{String => Array<Hash>}] issue key => status-change histories
|
|
150
|
+
def changelogs(issue_keys)
|
|
151
|
+
keys = usable_issue_keys(issue_keys)
|
|
152
|
+
return {} if keys.empty?
|
|
153
|
+
|
|
154
|
+
errors = []
|
|
155
|
+
result = keys.each_slice(BULK_FETCH_SIZE).with_object({}) do |batch, acc|
|
|
156
|
+
acc.merge!(slice_changelogs(batch, errors))
|
|
157
|
+
end
|
|
158
|
+
raise errors.first if result.empty? && errors.any?
|
|
159
|
+
|
|
160
|
+
result
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# GET /rest/api/3/issue/{key}/changelog
|
|
164
|
+
#
|
|
165
|
+
# The paginated endpoint, not the `?expand=changelog` embedding: the embedded
|
|
166
|
+
# form is truncated at the server's page size with no way to ask for the rest,
|
|
167
|
+
# which silently shortens the time calculated for a long-lived ticket.
|
|
168
|
+
#
|
|
169
|
+
# @return [Array<Hash>] changelog histories, each with 'created' and 'items'
|
|
170
|
+
def changelog(issue_key)
|
|
171
|
+
entries = []
|
|
172
|
+
start_at = 0
|
|
173
|
+
pages = 0
|
|
174
|
+
|
|
175
|
+
loop do
|
|
176
|
+
payload = get("#{API_PATH}/issue/#{issue_path_segment(issue_key)}/changelog",
|
|
177
|
+
startAt: start_at, maxResults: PAGE_SIZE)
|
|
178
|
+
batch = payload['values'] || []
|
|
179
|
+
entries.concat(batch)
|
|
180
|
+
|
|
181
|
+
start_at += batch.size
|
|
182
|
+
pages += 1
|
|
183
|
+
break if batch.empty? || payload['isLast'] || start_at >= payload['total'].to_i || pages >= MAX_PAGES
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
entries
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# GET /rest/api/3/project/search
|
|
190
|
+
# @return [Array<Hash>] projects with 'key' and 'name'
|
|
191
|
+
def projects
|
|
192
|
+
values = []
|
|
193
|
+
start_at = 0
|
|
194
|
+
pages = 0
|
|
195
|
+
|
|
196
|
+
loop do
|
|
197
|
+
payload = get("#{API_PATH}/project/search", startAt: start_at, maxResults: PAGE_SIZE)
|
|
198
|
+
batch = payload['values'] || []
|
|
199
|
+
values.concat(batch)
|
|
200
|
+
|
|
201
|
+
start_at += batch.size
|
|
202
|
+
pages += 1
|
|
203
|
+
break if batch.empty? || payload['isLast'] || pages >= MAX_PAGES
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
values
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# GET /rest/api/3/status — every status on the instance, with its category.
|
|
210
|
+
# Not paginated. Used to build the status-id → statusCategory map.
|
|
211
|
+
# @return [Array<Hash>] statuses with 'id', 'name' and nested 'statusCategory'
|
|
212
|
+
def statuses
|
|
213
|
+
path = "#{API_PATH}/status"
|
|
214
|
+
expect_type(connection.get(path).body, Array, path)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
private
|
|
218
|
+
|
|
219
|
+
# Keys are validated one at a time: the guard rejecting one key must not cost the
|
|
220
|
+
# other 99 in its slice, and the warning names the key so it can be chased.
|
|
221
|
+
def usable_issue_keys(issue_keys)
|
|
222
|
+
issue_keys.filter_map do |key|
|
|
223
|
+
issue_path_segment(key)
|
|
224
|
+
rescue Validators::ValidationError => e
|
|
225
|
+
warn "[jira] Warning: skipping unusable issue key #{key.inspect} (#{e.message})"
|
|
226
|
+
nil
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def slice_changelogs(batch, errors)
|
|
231
|
+
payload = post("#{API_PATH}/issue/bulkfetch",
|
|
232
|
+
issueIdsOrKeys: batch, fields: %w[status], expand: %w[changelog])
|
|
233
|
+
warn_issue_errors(payload)
|
|
234
|
+
collect_histories(payload['issues'] || [], errors)
|
|
235
|
+
rescue Faraday::Error, Error => e
|
|
236
|
+
errors << e
|
|
237
|
+
warn "[jira] Warning: changelog bulkfetch failed for #{batch.first}..#{batch.last} " \
|
|
238
|
+
"(#{e.message}); falling back to one request per issue"
|
|
239
|
+
per_issue_changelogs(batch, errors)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def collect_histories(issues, errors)
|
|
243
|
+
issues.each_with_object({}) do |issue, acc|
|
|
244
|
+
key = issue['key']
|
|
245
|
+
next unless key
|
|
246
|
+
|
|
247
|
+
acc[key] = histories_for(issue)
|
|
248
|
+
rescue Faraday::Error, Error => e
|
|
249
|
+
errors << e
|
|
250
|
+
warn "[jira] Warning: changelog fetch failed for #{key} (#{e.message})"
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def per_issue_changelogs(batch, errors)
|
|
255
|
+
batch.each_with_object({}) do |key, acc|
|
|
256
|
+
acc[key] = status_histories(changelog(key))
|
|
257
|
+
rescue Faraday::Error, Error => e
|
|
258
|
+
errors << e
|
|
259
|
+
warn "[jira] Warning: changelog fetch failed for #{key} (#{e.message})"
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# bulkfetch reports per-issue problems (deleted issue, no permission) alongside
|
|
264
|
+
# the issues it could return, so without this the one failure mode that costs
|
|
265
|
+
# tickets in an HTTP 200 would warn about nothing.
|
|
266
|
+
def warn_issue_errors(payload)
|
|
267
|
+
Array(payload['issueErrors']).each do |issue_error|
|
|
268
|
+
messages = Array(issue_error['errorMessages']).join('; ')
|
|
269
|
+
detail = messages.empty? ? issue_error.inspect : messages
|
|
270
|
+
warn "[jira] Warning: bulkfetch could not return an issue (#{detail})"
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Histories embedded by `expand: changelog`, re-read through the paginated
|
|
275
|
+
# endpoint unless the response states they are all there. Two things about that
|
|
276
|
+
# comparison:
|
|
277
|
+
# - `total` counts every field change, not just status ones, so a ticket with a
|
|
278
|
+
# long description history is re-read for status changes it may not have. The
|
|
279
|
+
# embedded form offers no way to ask for the rest and `expand` cannot filter by
|
|
280
|
+
# field, so the re-read is the only way to know; MAX_PAGES bounds its cost.
|
|
281
|
+
# - a response carrying no `total` says nothing about truncation, which is not the
|
|
282
|
+
# same as saying there is nothing more to fetch, so it re-reads too.
|
|
283
|
+
def histories_for(issue)
|
|
284
|
+
embedded = issue['changelog'] || {}
|
|
285
|
+
total = embedded['total']
|
|
286
|
+
histories = embedded['histories'] || []
|
|
287
|
+
return status_histories(histories) if total.is_a?(Integer) && histories.size >= total
|
|
288
|
+
|
|
289
|
+
status_histories(changelog(issue['key']))
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Only status changes are ever read from a changelog (see
|
|
293
|
+
# Jira#calculate_time_from_changelog), and a batch holds 100 tickets' histories
|
|
294
|
+
# at once — description diffs included — so the rest is dropped on arrival.
|
|
295
|
+
def status_histories(histories)
|
|
296
|
+
histories.select do |history|
|
|
297
|
+
Array(history['items']).any? { |item| item['field'] == 'status' }
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def get(path, params = {})
|
|
302
|
+
expect_type(connection.get(path, params).body, Hash, path)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def post(path, payload)
|
|
306
|
+
expect_type(connection.post(path, payload).body, Hash, path)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# `f.response :json` only parses JSON content types. A 200 carrying HTML — a
|
|
310
|
+
# proxy interstitial, a maintenance page — leaves a String here, and treating
|
|
311
|
+
# that as an empty payload would report "no work" for an outage.
|
|
312
|
+
def expect_type(body, type, path)
|
|
313
|
+
return body if body.is_a?(type)
|
|
314
|
+
|
|
315
|
+
raise Error, "Unexpected response body from #{path}: expected #{type}, got #{body.class}"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Issue keys come from Jira itself, but they are interpolated into a URL path,
|
|
319
|
+
# so they get the same guard ClickUp applies to team_id. Jira keys (SCRUM-123)
|
|
320
|
+
# satisfy Validators::ALPHANUMERIC_ID_REGEX.
|
|
321
|
+
def issue_path_segment(issue_key)
|
|
322
|
+
Validators.validate_id!(issue_key, name: 'issue_key')
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def connection
|
|
326
|
+
@connection ||= Faraday.new(url: @base_url) do |f|
|
|
327
|
+
f.request :json
|
|
328
|
+
f.response :raise_error
|
|
329
|
+
f.request :retry, max: RETRY_MAX, interval: @retry_interval,
|
|
330
|
+
max_interval: RETRY_MAX_INTERVAL, interval_randomness: RETRY_RANDOMNESS,
|
|
331
|
+
retry_statuses: RETRY_STATUSES, methods: RETRY_METHODS
|
|
332
|
+
f.response :json
|
|
333
|
+
f.headers['Authorization'] = basic_auth_header
|
|
334
|
+
f.headers['Accept'] = 'application/json'
|
|
335
|
+
f.options.timeout = 15
|
|
336
|
+
f.options.open_timeout = 5
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def basic_auth_header
|
|
341
|
+
"Basic #{Base64.strict_encode64("#{@username}:#{@api_token}")}"
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
|
|
5
|
+
module Snitcher
|
|
6
|
+
module Sources
|
|
7
|
+
class Jira < Source
|
|
8
|
+
# Resolves a Jira status ID to its language-independent statusCategory key.
|
|
9
|
+
#
|
|
10
|
+
# The changelog does not carry statusCategory. A status change gives only
|
|
11
|
+
# from/fromString/to/toString, where `to` is the numeric status ID and `toString`
|
|
12
|
+
# its localised name ("In Arbeit", "W realizacji"). Resolving the ID through the
|
|
13
|
+
# instance's status catalogue keeps time calculation independent of language —
|
|
14
|
+
# the design principle stated in the ARCHITECTURE NOTES on Jira.
|
|
15
|
+
#
|
|
16
|
+
# The catalogue is fetched once per source instance and memoised. A failed fetch
|
|
17
|
+
# degrades to an empty map, which pushes extract_status_category onto its
|
|
18
|
+
# name-based fallback rather than failing the whole report.
|
|
19
|
+
# @api private
|
|
20
|
+
class StatusCategories
|
|
21
|
+
# Jira's "No Category" statusCategory. It carries no information about whether
|
|
22
|
+
# the status means started, finished or neither, but it is a truthy String, so
|
|
23
|
+
# returning it would short-circuit the `||` in Jira#extract_status_category and
|
|
24
|
+
# hide the configured status names — in exactly the case they are needed.
|
|
25
|
+
UNCATEGORISED_KEY = 'undefined'
|
|
26
|
+
|
|
27
|
+
def initialize(client)
|
|
28
|
+
@client = client
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param status_id [String, Integer, nil] the changelog item's `to` value
|
|
32
|
+
# @return [String, nil] 'new', 'indeterminate', 'done', or nil when unknown
|
|
33
|
+
def category_for(status_id)
|
|
34
|
+
return nil if status_id.nil?
|
|
35
|
+
|
|
36
|
+
key = map[status_id.to_s]
|
|
37
|
+
key unless key == UNCATEGORISED_KEY
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def map
|
|
43
|
+
@map ||= build_map
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_map
|
|
47
|
+
@client.statuses.each_with_object({}) do |status, acc|
|
|
48
|
+
id = status['id']
|
|
49
|
+
key = status.dig('statusCategory', 'key')
|
|
50
|
+
acc[id.to_s] = key if id && key
|
|
51
|
+
end
|
|
52
|
+
rescue Faraday::Error, Snitcher::Error => e
|
|
53
|
+
warn "[jira] Warning: could not fetch status categories (#{e.message}); " \
|
|
54
|
+
'falling back to status-name mapping'
|
|
55
|
+
{}
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|