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