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