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,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'faraday/retry'
5
+ require 'json'
6
+ require 'time'
7
+
8
+ module Snitcher
9
+ module Sources
10
+ class Linear < Source
11
+ API_URL = 'https://api.linear.app/graphql'
12
+ PAGE_SIZE = 100
13
+ MAX_PAGES = 10 # Safety limit to prevent infinite loops
14
+
15
+ def initialize(config = {})
16
+ super
17
+ @api_key = Source.presence(config[:api_key]) || Source.env('LINEAR_API_KEY')
18
+ end
19
+
20
+ def source_name
21
+ :linear
22
+ end
23
+
24
+ def available?
25
+ !@api_key.nil?
26
+ end
27
+
28
+ def unavailable_reason
29
+ 'LINEAR_API_KEY is not set'
30
+ end
31
+
32
+ # Discover all workflow states across Linear teams.
33
+ # Uses the `type` field (language-independent) to categorize states.
34
+ #
35
+ # @param config [Hash] optional config overrides
36
+ # @return [Hash] status discovery result
37
+ def self.discover_statuses(config = {})
38
+ instance = new(config)
39
+ return { error: 'Linear not configured' } unless instance.available?
40
+
41
+ instance.send(:fetch_workflow_states)
42
+ end
43
+
44
+ def fetch(user_email:, since:, until_time:)
45
+ return [] unless available?
46
+
47
+ user_id = find_user_id(primary_email(user_email))
48
+ return [] unless user_id
49
+
50
+ issues = fetch_completed_issues(user_id: user_id, since: since, until_time: until_time)
51
+
52
+ issues.map do |issue|
53
+ cycle_time = calculate_cycle_time(issue)
54
+
55
+ build_work_item(
56
+ external_id: issue['identifier'],
57
+ title: issue['title'],
58
+ completed_at: safe_parse_iso8601(issue['completedAt']),
59
+ time_spent_seconds: cycle_time,
60
+ time_source: cycle_time ? :calculated : nil,
61
+ url: issue['url'],
62
+ metadata: {
63
+ estimate: issue['estimate'],
64
+ state: issue.dig('state', 'name'),
65
+ cycle: issue.dig('cycle', 'name'),
66
+ project: issue.dig('project', 'name'),
67
+ started_at: issue['startedAt']
68
+ }
69
+ )
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def connection
76
+ @connection ||= Faraday.new(url: API_URL) do |f|
77
+ f.request :json
78
+ f.request :retry, max: 3, interval: 0.5, retry_statuses: [429, 500, 502, 503, 504]
79
+ f.response :json
80
+ f.headers['Authorization'] = @api_key
81
+ f.headers['Content-Type'] = 'application/json'
82
+ f.options.timeout = 15
83
+ f.options.open_timeout = 5
84
+ end
85
+ end
86
+
87
+ def query(graphql_query, variables = {})
88
+ response = connection.post do |req|
89
+ req.body = { query: graphql_query, variables: variables }.to_json
90
+ end
91
+
92
+ data = response.body
93
+ raise Error, data['errors'].map { |e| e['message'] }.join(', ') if data['errors']
94
+
95
+ data['data']
96
+ end
97
+
98
+ def find_user_id(email)
99
+ result = query(<<~GRAPHQL, email: email)
100
+ query($email: String!) {
101
+ users(filter: { email: { eq: $email } }) {
102
+ nodes {
103
+ id
104
+ }
105
+ }
106
+ }
107
+ GRAPHQL
108
+
109
+ result.dig('users', 'nodes', 0, 'id')
110
+ end
111
+
112
+ def fetch_completed_issues(user_id:, since:, until_time:)
113
+ all_issues = []
114
+ cursor = nil
115
+ page = 0
116
+
117
+ loop do
118
+ result = query(issues_query, userId: user_id, since: since.iso8601, until: until_time.iso8601, cursor: cursor)
119
+
120
+ issues_data = result['issues'] || {}
121
+ nodes = issues_data['nodes'] || []
122
+ all_issues.concat(nodes)
123
+
124
+ page_info = issues_data['pageInfo'] || {}
125
+ break unless page_info['hasNextPage']
126
+
127
+ cursor = page_info['endCursor']
128
+ page += 1
129
+ break if page >= MAX_PAGES
130
+ end
131
+
132
+ all_issues
133
+ end
134
+
135
+ def issues_query
136
+ <<~GRAPHQL
137
+ query($userId: ID!, $since: DateTimeOrDuration!, $until: DateTimeOrDuration!, $cursor: String) {
138
+ issues(
139
+ filter: {
140
+ assignee: { id: { eq: $userId } }
141
+ completedAt: { gte: $since, lte: $until }
142
+ }
143
+ first: #{PAGE_SIZE}
144
+ after: $cursor
145
+ ) {
146
+ pageInfo {
147
+ hasNextPage
148
+ endCursor
149
+ }
150
+ nodes {
151
+ id
152
+ identifier
153
+ title
154
+ url
155
+ completedAt
156
+ startedAt
157
+ createdAt
158
+ estimate
159
+ state { name }
160
+ cycle { name }
161
+ project { name }
162
+ }
163
+ }
164
+ }
165
+ GRAPHQL
166
+ end
167
+
168
+ # Calculate cycle time (completedAt - startedAt), filtered by working hours when configured.
169
+ # When working_hours config is nil, returns raw wall-clock seconds (backward compatible).
170
+ #
171
+ # NOTE: Unlike Jira's worklog-based time, this is derived from two timestamps
172
+ # (completedAt - startedAt), so working-hours filtering is an approximation.
173
+ #
174
+ # @param issue [Hash] Linear issue with 'startedAt' and 'completedAt' fields
175
+ # @return [Integer, nil] seconds (0 or positive when working_hours configured,
176
+ # may be negative for inverted timestamps when working_hours is nil), or nil if timestamps missing
177
+ def calculate_cycle_time(issue)
178
+ return nil unless issue['startedAt'] && issue['completedAt']
179
+
180
+ # Time.iso8601 is stricter than Time.parse — Linear API guarantees ISO 8601 format;
181
+ # stricter parsing prevents silent misparse of unexpected formats.
182
+ started = Time.iso8601(issue['startedAt'])
183
+ completed = Time.iso8601(issue['completedAt'])
184
+ WorkingHoursCalculator.seconds_between(started, completed, @working_hours)
185
+ rescue ArgumentError => e
186
+ warn "[Snitcher::Linear] failed to parse timestamps for #{issue['identifier']}: #{e.message}"
187
+ nil
188
+ end
189
+
190
+ # Safely parse an ISO 8601 timestamp string.
191
+ # Returns nil for nil/empty input or malformed strings (logs a warning).
192
+ def safe_parse_iso8601(value)
193
+ return nil if value.nil? || value.empty?
194
+
195
+ Time.iso8601(value)
196
+ rescue ArgumentError => e
197
+ warn "[Snitcher::Linear] failed to parse timestamp '#{value}': #{e.message}"
198
+ nil
199
+ end
200
+
201
+ # Fetches all workflow states from Linear and categorizes them by type.
202
+ #
203
+ # Linear workflow state types are language-independent:
204
+ # - 'completed' -> done_statuses
205
+ # - 'started' -> in_progress_statuses
206
+ # - 'unstarted' -> todo_statuses
207
+ # - 'canceled' -> canceled_statuses
208
+ #
209
+ # NOTE: This query fetches up to 200 workflow states (GraphQL pagination limit).
210
+ # Workspaces with more than 200 states across all teams will have some states
211
+ # truncated. This is rare in practice.
212
+ #
213
+ # @return [Hash] categorized statuses or error
214
+ def fetch_workflow_states
215
+ result = query(<<~GRAPHQL)
216
+ query {
217
+ workflowStates(first: 200) {
218
+ nodes {
219
+ id
220
+ name
221
+ type
222
+ color
223
+ team { id key name }
224
+ }
225
+ }
226
+ }
227
+ GRAPHQL
228
+
229
+ states = result.dig('workflowStates', 'nodes') || []
230
+
231
+ # Single-pass categorization for performance (O(n) instead of O(5n))
232
+ categorized = { done: Set.new, in_progress: Set.new, todo: Set.new, canceled: Set.new, all: [] }
233
+ states.each do |s|
234
+ state_name = s['name']
235
+ categorized[:all] << { name: state_name, type: s['type'], team: s.dig('team', 'key') }
236
+
237
+ case s['type']
238
+ when 'completed' then categorized[:done] << state_name
239
+ when 'started' then categorized[:in_progress] << state_name
240
+ when 'unstarted' then categorized[:todo] << state_name
241
+ when 'canceled' then categorized[:canceled] << state_name
242
+ end
243
+ end
244
+
245
+ {
246
+ done_statuses: categorized[:done].to_a,
247
+ in_progress_statuses: categorized[:in_progress].to_a,
248
+ todo_statuses: categorized[:todo].to_a,
249
+ canceled_statuses: categorized[:canceled].to_a,
250
+ all_statuses: categorized[:all].uniq
251
+ }
252
+ rescue StandardError => e
253
+ { error: "Failed to fetch Linear workflow states: #{e.message}" }
254
+ end
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Snitcher
4
+ module Sources
5
+ # Base class for all data sources.
6
+ #
7
+ # == Creating a New Source
8
+ #
9
+ # To add a new data source:
10
+ #
11
+ # 1. Create a new class inheriting from Source in lib/snitcher/sources/
12
+ # 2. Implement the required methods: #source_name, #available?, #fetch
13
+ # 3. Optionally override #unavailable_reason to say what setting is missing
14
+ # 4. Register the source in Orchestrator::SOURCE_MAP (lib/snitcher/orchestrator.rb)
15
+ #
16
+ # == Example
17
+ #
18
+ # class MySource < Source
19
+ # def source_name
20
+ # :my_source
21
+ # end
22
+ #
23
+ # def available?
24
+ # !Source.env('MY_SOURCE_API_KEY').nil?
25
+ # end
26
+ #
27
+ # def fetch(user_email:, since:, until_time:)
28
+ # # Fetch and return array of WorkItems
29
+ # []
30
+ # end
31
+ # end
32
+ #
33
+ # # Then in orchestrator.rb, add to SOURCE_MAP:
34
+ # SOURCE_MAP = {
35
+ # # ... existing sources ...
36
+ # my_source: Sources::MySource
37
+ # }.freeze
38
+ #
39
+ class Source
40
+ # Working hours this source filters calculated time to, or nil for raw wall-clock.
41
+ # Seeded from the config the source was built with; Orchestrator replaces it per
42
+ # person when working_hours.people names whoever a report is for. One writable slot
43
+ # is enough because the orchestrator builds its sources once and walks people
44
+ # sequentially (see Client#work_reports) — there is never a second person in flight.
45
+ attr_accessor :working_hours
46
+
47
+ def initialize(config = {})
48
+ @config = config
49
+ @working_hours = config[:working_hours]
50
+ end
51
+
52
+ # @return [Symbol] unique identifier for this source (e.g., :jira, :git)
53
+ def source_name
54
+ raise NotImplementedError, "#{self.class} must implement #source_name"
55
+ end
56
+
57
+ # @return [Boolean] true if the source is configured and ready to use
58
+ def available?
59
+ raise NotImplementedError, "#{self.class} must implement #available?"
60
+ end
61
+
62
+ # Why #available? is false, as one operator-facing phrase naming the setting to change.
63
+ # Read only when #available? is false: Orchestrator puts it verbatim into failed_sources
64
+ # and on stderr, so a skipped source says what it needs rather than only that something
65
+ # is missing. Override to be specific — the default is honest but blunt.
66
+ # @return [String]
67
+ def unavailable_reason
68
+ 'not configured'
69
+ end
70
+
71
+ # Fetch work items from the source.
72
+ #
73
+ # The Orchestrator resolves each user to one or more source-specific emails
74
+ # (see sources.<name>.emails / .user), so +user_email+ may be a single String
75
+ # or an Array<String>. Use #user_emails / #primary_email to normalize both forms.
76
+ #
77
+ # @param user_email [String, Array<String>] email(s) of the user to fetch work for
78
+ # @param since [Time] start of the time range
79
+ # @param until_time [Time] end of the time range
80
+ # @return [Array<WorkItem>] list of work items
81
+ def fetch(user_email:, since:, until_time:)
82
+ raise NotImplementedError, "#{self.class} must implement #fetch"
83
+ end
84
+
85
+ # Returns nil for blank strings and placeholder values from .env.example.
86
+ # Treats values like "your_linear_api_key" or "" as unconfigured.
87
+ PLACEHOLDER_PATTERN = /\Ayour_/i
88
+
89
+ # @param value [String, nil] raw env/config value
90
+ # @return [String, nil] the value if meaningful, nil otherwise
91
+ def self.presence(value)
92
+ return nil if value.nil? || value.strip.empty?
93
+ return nil if value.match?(PLACEHOLDER_PATTERN)
94
+
95
+ value
96
+ end
97
+
98
+ # Reads a credential from the environment, preferring the SNITCHER_-prefixed
99
+ # name. Applications that embed the gem name their variables
100
+ # SNITCHER_JIRA_API_TOKEN, SNITCHER_LINEAR_API_KEY and so on, so it is obvious
101
+ # from the host's .env which library they belong to. The bare name remains
102
+ # supported — the standalone executables and existing setups rely on it — but
103
+ # the prefixed one wins, otherwise a stale bare variable would silently shadow
104
+ # the explicit declaration.
105
+ #
106
+ # @param name [String] unprefixed variable name, e.g. "JIRA_API_TOKEN"
107
+ # @return [String, nil] the configured value, nil when unset or a placeholder
108
+ def self.env(name)
109
+ presence(ENV.fetch("SNITCHER_#{name}", nil)) || presence(ENV.fetch(name, nil))
110
+ end
111
+
112
+ private
113
+
114
+ # Normalizes the +user_email+ argument (String or Array<String>) into a flat
115
+ # Array<String>. Sources that can match several addresses at once (e.g. git,
116
+ # matching commit authors) filter against every entry.
117
+ # @param user_email [String, Array<String>]
118
+ # @return [Array<String>]
119
+ def user_emails(user_email)
120
+ Array(user_email)
121
+ end
122
+
123
+ # Returns the primary email for sources whose identity is 1:1 (Jira/Linear/ClickUp
124
+ # resolve one account per user). Extra addresses in a multi-value config are ignored.
125
+ # @param user_email [String, Array<String>]
126
+ # @return [String, nil]
127
+ def primary_email(user_email)
128
+ Array(user_email).first
129
+ end
130
+
131
+ # Helper to build a WorkItem with the source automatically set.
132
+ #
133
+ # @param attributes [Hash] WorkItem attributes (except :source)
134
+ # @return [WorkItem]
135
+ def build_work_item(**attributes)
136
+ WorkItem.new(source: source_name, **attributes)
137
+ end
138
+ end
139
+ end
140
+ end