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,347 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module Snitcher
|
|
6
|
+
module Sources
|
|
7
|
+
# Reads work items from the commit history of a local checkout.
|
|
8
|
+
#
|
|
9
|
+
# Every git invocation goes through CommandRunner, which raises when git exits
|
|
10
|
+
# non-zero or has to be killed for hanging. That is deliberate: the only other thing
|
|
11
|
+
# this source could return on a broken git is an empty list, and an empty list is
|
|
12
|
+
# indistinguishable from a week in which nobody committed. Orchestrator#fetch catches
|
|
13
|
+
# the exception, records the source in #failed_sources and marks the report
|
|
14
|
+
# incomplete — the same route a Jira or ClickUp outage takes.
|
|
15
|
+
#
|
|
16
|
+
# Two commands are exempt, via CommandRunner#output_or_nil, because a non-zero exit
|
|
17
|
+
# from them is a legitimate answer rather than an outage: see #remote_base_url and
|
|
18
|
+
# #diff_stats. They warn and degrade, and only that one detail is lost. A third case
|
|
19
|
+
# is exempt for the same reason and reports nothing at all: a checkout with no commits
|
|
20
|
+
# on it yet, which git fails on but which is an empty week, not an outage — see
|
|
21
|
+
# #no_commits_yet?.
|
|
22
|
+
class Git < Source
|
|
23
|
+
# Matches JIRA-style ticket IDs: 2-10 uppercase letters, hyphen, digits
|
|
24
|
+
# Examples: "PROJ-123", "ENG-1", "AB-99999"
|
|
25
|
+
TICKET_REGEX = /([A-Z]{2,10}-\d+)/
|
|
26
|
+
|
|
27
|
+
# Matches GitHub/GitLab PR/MR numbers prefixed with #
|
|
28
|
+
# Examples: "#123", "#1", "#99999"
|
|
29
|
+
PR_NUMBER_REGEX = /#(\d+)/
|
|
30
|
+
|
|
31
|
+
def initialize(config = {})
|
|
32
|
+
super
|
|
33
|
+
# Whether the path was chosen or merely inherited is what #unavailable_reason reports:
|
|
34
|
+
# a configured path that is not a checkout is a typo to fix, an inherited working
|
|
35
|
+
# directory is just where the process happened to run. Mirrors the || below rather
|
|
36
|
+
# than Source.presence, so the two stay in step.
|
|
37
|
+
@repo_path_configured = !config[:repo_path].nil?
|
|
38
|
+
@repo_path = config[:repo_path] || Dir.pwd
|
|
39
|
+
@command_runner = CommandRunner.new(repo_path: @repo_path, timeout_seconds: config[:timeout])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def source_name
|
|
43
|
+
:git
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def available?
|
|
47
|
+
Dir.exist?(File.join(@repo_path, '.git'))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def unavailable_reason
|
|
51
|
+
return "sources.git.repo_path (#{@repo_path}) is not a git checkout" if @repo_path_configured
|
|
52
|
+
|
|
53
|
+
"no sources.git.repo_path is set and the working directory (#{@repo_path}) " \
|
|
54
|
+
'is not a git checkout'
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def fetch(user_email:, since:, until_time:)
|
|
58
|
+
return [] unless available?
|
|
59
|
+
|
|
60
|
+
emails = user_emails(user_email)
|
|
61
|
+
items = []
|
|
62
|
+
items += fetch_merge_items(emails: emails, since: since, until_time: until_time)
|
|
63
|
+
items += fetch_non_merge_items(emails: emails, since: since, until_time: until_time)
|
|
64
|
+
deduplicate_by_pr(items)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def fetch_merge_items(emails:, since:, until_time:)
|
|
70
|
+
find_merge_commits(since: since, until_time: until_time).filter_map do |merge|
|
|
71
|
+
authors = pr_authors(merge[:hash])
|
|
72
|
+
next unless authors.intersect?(emails)
|
|
73
|
+
|
|
74
|
+
stats = diff_stats(from: "#{merge[:hash]}^1", to: "#{merge[:hash]}^2")
|
|
75
|
+
ticket_ids = extract_ticket_ids(merge[:subject])
|
|
76
|
+
ticket_ids = extract_ticket_ids(merge[:body].to_s) if ticket_ids.empty? && merge[:body]
|
|
77
|
+
|
|
78
|
+
build_work_item(
|
|
79
|
+
external_id: ticket_ids.first || merge[:hash][0..7],
|
|
80
|
+
title: clean_title(merge[:subject], merge[:body]),
|
|
81
|
+
completed_at: merge[:date],
|
|
82
|
+
time_spent_seconds: nil,
|
|
83
|
+
time_source: nil,
|
|
84
|
+
url: commit_url(merge[:hash]),
|
|
85
|
+
metadata: {
|
|
86
|
+
commit: merge[:hash],
|
|
87
|
+
commit_type: :merge,
|
|
88
|
+
ticket_ids: ticket_ids,
|
|
89
|
+
pr_number: extract_pr_number(merge[:subject]),
|
|
90
|
+
lines_added: stats[:added],
|
|
91
|
+
lines_deleted: stats[:deleted],
|
|
92
|
+
files_changed: stats[:files],
|
|
93
|
+
authors: authors
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def fetch_non_merge_items(emails:, since:, until_time:)
|
|
100
|
+
commits = find_non_merge_commits(since: since, until_time: until_time)
|
|
101
|
+
authors_by_hash = batch_commit_authors(commits.map { |c| c[:hash] })
|
|
102
|
+
|
|
103
|
+
commits.filter_map do |commit|
|
|
104
|
+
authors = authors_by_hash[commit[:hash]] || []
|
|
105
|
+
next unless authors.intersect?(emails)
|
|
106
|
+
|
|
107
|
+
stats = diff_stats(from: "#{commit[:hash]}~1", to: commit[:hash])
|
|
108
|
+
ticket_ids = extract_ticket_ids(commit[:subject])
|
|
109
|
+
pr_number = extract_pr_number(commit[:subject])
|
|
110
|
+
|
|
111
|
+
build_work_item(
|
|
112
|
+
external_id: ticket_ids.first || commit[:hash][0..7],
|
|
113
|
+
title: commit[:subject],
|
|
114
|
+
completed_at: commit[:date],
|
|
115
|
+
time_spent_seconds: nil,
|
|
116
|
+
time_source: nil,
|
|
117
|
+
url: commit_url(commit[:hash]),
|
|
118
|
+
metadata: {
|
|
119
|
+
commit: commit[:hash],
|
|
120
|
+
commit_type: pr_number ? :squash : :direct,
|
|
121
|
+
ticket_ids: ticket_ids,
|
|
122
|
+
pr_number: pr_number,
|
|
123
|
+
lines_added: stats[:added],
|
|
124
|
+
lines_deleted: stats[:deleted],
|
|
125
|
+
files_changed: stats[:files],
|
|
126
|
+
authors: authors
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def find_merge_commits(since:, until_time:)
|
|
133
|
+
fetch_git_log_records(['--merges'], since: since, until_time: until_time)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def find_non_merge_commits(since:, until_time:)
|
|
137
|
+
fetch_git_log_records(['--no-merges', '--first-parent'], since: since, until_time: until_time)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Parses structured git log output into work-item field hashes.
|
|
141
|
+
#
|
|
142
|
+
# Raises when git fails: this is what feeds #fetch, and an empty list here is the
|
|
143
|
+
# report's entire git section. The one exception is a checkout with no commits on
|
|
144
|
+
# it yet, which git also reports as a failure but which genuinely has nothing to
|
|
145
|
+
# report — see #no_commits_yet?.
|
|
146
|
+
#
|
|
147
|
+
# A checkout that has commits, none of them in the requested window, never reaches
|
|
148
|
+
# either branch: git exits zero with empty output, and that has always read as the
|
|
149
|
+
# empty week it is.
|
|
150
|
+
def fetch_git_log_records(flags, since:, until_time:)
|
|
151
|
+
output = @command_runner.output(
|
|
152
|
+
['log', *flags, "--since=#{since.iso8601}", "--until=#{until_time.iso8601}",
|
|
153
|
+
# Use \x01/\x00 as record/field separators because %b (body) can contain newlines
|
|
154
|
+
'--format=%x01%H%x00%ae%x00%aI%x00%s%x00%b']
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
output.split("\x01").filter_map do |record|
|
|
158
|
+
next if record.strip.empty?
|
|
159
|
+
|
|
160
|
+
hash, author_email, date_str, subject, body = record.split("\x00", 5)
|
|
161
|
+
{
|
|
162
|
+
hash: hash.strip,
|
|
163
|
+
author_email: author_email,
|
|
164
|
+
date: Time.parse(date_str),
|
|
165
|
+
subject: subject,
|
|
166
|
+
body: body&.split("\n")&.find { |l| !l.strip.empty? }&.strip
|
|
167
|
+
}
|
|
168
|
+
end
|
|
169
|
+
rescue CommandRunner::CommandError
|
|
170
|
+
raise unless no_commits_yet?
|
|
171
|
+
|
|
172
|
+
[]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# True when the checkout is a readable repository whose HEAD carries no commit yet:
|
|
176
|
+
# a fresh `git init`, or a branch nobody has committed to.
|
|
177
|
+
#
|
|
178
|
+
# This has to be told apart from a checkout git cannot read at all, because git
|
|
179
|
+
# fails the same way for both — exit 128 from `git log`. "Nobody has committed
|
|
180
|
+
# anything" is an answer, and the report states it as zero items; "this checkout is
|
|
181
|
+
# broken" is not, and the report has to say the source failed. The two are separated
|
|
182
|
+
# structurally rather than by matching git's wording, which is localised: for an
|
|
183
|
+
# unborn HEAD the repository itself still resolves and only HEAD does not.
|
|
184
|
+
#
|
|
185
|
+
# Memoized: the orchestrator walks a batch of people on one source instance, and the
|
|
186
|
+
# repository does not grow a first commit halfway through the run.
|
|
187
|
+
def no_commits_yet?
|
|
188
|
+
return @no_commits_yet if defined?(@no_commits_yet)
|
|
189
|
+
|
|
190
|
+
@no_commits_yet = @command_runner.success?(%w[rev-parse --git-dir]) &&
|
|
191
|
+
!@command_runner.success?(%w[rev-parse --verify --quiet HEAD])
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Returns the HTTPS URL for a commit (e.g. "https://github.com/org/repo/commit/abc123"),
|
|
195
|
+
# or nil when the remote cannot be resolved.
|
|
196
|
+
def commit_url(hash)
|
|
197
|
+
remote_base_url&.then { |base| "#{base}/commit/#{hash}" }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Resolves the origin remote URL into a browsable HTTPS base URL.
|
|
201
|
+
# Handles SCP-style SSH (git@host:org/repo), ssh:// protocol, and HTTPS URLs.
|
|
202
|
+
# Strips embedded credentials from HTTPS URLs to prevent token leakage.
|
|
203
|
+
# Memoized (including nil on failure) — resolved once per source instance.
|
|
204
|
+
#
|
|
205
|
+
# Warns rather than raising on a failing git: a checkout with no `origin` is a
|
|
206
|
+
# normal local repo, and `git remote get-url` exits non-zero for it. All that is
|
|
207
|
+
# lost is the link on each item, so failing the source here would throw away a
|
|
208
|
+
# correct commit list over a missing hyperlink. Memoization means one warning.
|
|
209
|
+
def remote_base_url
|
|
210
|
+
return @remote_base_url if defined?(@remote_base_url)
|
|
211
|
+
|
|
212
|
+
output = @command_runner.output_or_nil(%w[remote get-url origin],
|
|
213
|
+
consequence: 'Items carry no commit links.')
|
|
214
|
+
@remote_base_url = output && normalize_remote_url(output.strip)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Converts a git remote URL to a browsable HTTPS base URL.
|
|
218
|
+
# @param url [String] raw remote URL from git
|
|
219
|
+
# @return [String] HTTPS URL without .git suffix or credentials
|
|
220
|
+
def normalize_remote_url(url)
|
|
221
|
+
return nil if url.empty?
|
|
222
|
+
|
|
223
|
+
normalized =
|
|
224
|
+
case url
|
|
225
|
+
when %r{^ssh://}
|
|
226
|
+
# ssh://git@github.com/org/repo.git → https://github.com/org/repo
|
|
227
|
+
url.sub(%r{^ssh://[^/]*@}, 'https://')
|
|
228
|
+
when /^git@/
|
|
229
|
+
# git@github.com:org/repo.git → https://github.com/org/repo
|
|
230
|
+
url.sub(/^git@([^:]+):/, 'https://\1/')
|
|
231
|
+
when %r{^https?://}
|
|
232
|
+
# Strip credentials from HTTPS URLs: https://token@github.com/... → https://github.com/...
|
|
233
|
+
url.sub(%r{://[^/]*@}, '://')
|
|
234
|
+
else
|
|
235
|
+
# Unrecognized schemes (git://, file://, etc.) — not browsable
|
|
236
|
+
return nil
|
|
237
|
+
end
|
|
238
|
+
normalized.sub(/\.git$/, '')
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Get all commit authors between the two parents of the merge.
|
|
242
|
+
#
|
|
243
|
+
# Raises when git fails, because the caller reads the result as "this merge is
|
|
244
|
+
# not yours" and drops the item. The known non-outage failure is a shallow clone,
|
|
245
|
+
# where a merge parent may be missing — but a shallow clone also truncates the
|
|
246
|
+
# log this source walks, so a report built on one is wrong in ways that are worth
|
|
247
|
+
# a failed source rather than a quietly short list.
|
|
248
|
+
def pr_authors(merge_hash)
|
|
249
|
+
output = @command_runner.output(
|
|
250
|
+
['log', '--format=%ae', '--no-merges', "#{merge_hash}^1..#{merge_hash}^2"]
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
output.lines.map(&:strip).uniq
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Fetches authors for multiple commits in a single git subprocess call,
|
|
257
|
+
# returning a hash of { commit_hash => [author_emails] }.
|
|
258
|
+
# Parses Co-authored-by trailers to include co-authors.
|
|
259
|
+
#
|
|
260
|
+
# Raises when git fails: authorship is the filter every non-merge commit passes
|
|
261
|
+
# through, so an empty result drops the entire non-merge half of the report.
|
|
262
|
+
def batch_commit_authors(commit_hashes)
|
|
263
|
+
return {} if commit_hashes.empty?
|
|
264
|
+
|
|
265
|
+
# Use \x01 as record separator between commits
|
|
266
|
+
output = @command_runner.output(['log', '--no-walk', '--format=%x01%H%n%ae%n%B', *commit_hashes])
|
|
267
|
+
|
|
268
|
+
result = {}
|
|
269
|
+
output.split("\x01").each do |record|
|
|
270
|
+
next if record.strip.empty?
|
|
271
|
+
|
|
272
|
+
lines = record.lines.map(&:strip)
|
|
273
|
+
hash = lines.first
|
|
274
|
+
authors = [lines[1]].compact.reject(&:empty?)
|
|
275
|
+
|
|
276
|
+
lines.each do |line|
|
|
277
|
+
match = line.match(/^Co-authored-by:\s*[^<]+<([^>]+)>/i)
|
|
278
|
+
authors << match[1] if match
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
result[hash] = authors.uniq
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
result
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Line counts for one commit, zero when they cannot be read.
|
|
288
|
+
#
|
|
289
|
+
# Warns rather than raising: `git diff <commit>~1 <commit>` exits non-zero for a
|
|
290
|
+
# root commit, which has no parent, and that is an ordinary commit to report. The
|
|
291
|
+
# item still appears with everything that identifies it — only its line counts are
|
|
292
|
+
# missing — so this is the one place where degrading costs less than failing.
|
|
293
|
+
def diff_stats(from:, to:)
|
|
294
|
+
output = @command_runner.output_or_nil(['diff', '--numstat', from, to],
|
|
295
|
+
consequence: "Line counts for #{to} read as zero.")
|
|
296
|
+
return { added: 0, deleted: 0, files: 0 } unless output
|
|
297
|
+
|
|
298
|
+
added = deleted = files = 0
|
|
299
|
+
|
|
300
|
+
output.lines.each do |line|
|
|
301
|
+
parts = line.strip.split("\t")
|
|
302
|
+
next if parts[0] == '-' # binary file
|
|
303
|
+
|
|
304
|
+
added += parts[0].to_i
|
|
305
|
+
deleted += parts[1].to_i
|
|
306
|
+
files += 1
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
{ added: added, deleted: deleted, files: files }
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def extract_ticket_ids(subject)
|
|
313
|
+
subject.scan(TICKET_REGEX).flatten.uniq
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def extract_pr_number(subject)
|
|
317
|
+
match = subject.match(PR_NUMBER_REGEX)
|
|
318
|
+
match ? match[1].to_i : nil
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Clean merge commit subject by removing common prefixes (GitHub/GitLab formats).
|
|
322
|
+
# For GitHub-style merges the subject is just the merge boilerplate; the actual
|
|
323
|
+
# PR title lives in the commit body, so fall back to body when subject strips empty.
|
|
324
|
+
# Falls back to the original subject if body is also absent, ensuring a non-nil title.
|
|
325
|
+
def clean_title(subject, body = nil)
|
|
326
|
+
cleaned = subject
|
|
327
|
+
.sub(/^Merge pull request #\d+ from \S+\s*/, '')
|
|
328
|
+
.sub(/^Merge branch '\S+'\s*(into \S+)?/, '')
|
|
329
|
+
.strip
|
|
330
|
+
|
|
331
|
+
return cleaned unless cleaned.empty?
|
|
332
|
+
return body unless body.nil? || body.empty?
|
|
333
|
+
|
|
334
|
+
subject
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Merge items are added first, so merge commits take precedence over squash duplicates
|
|
338
|
+
def deduplicate_by_pr(items)
|
|
339
|
+
seen = {}
|
|
340
|
+
items.select do |item|
|
|
341
|
+
pr = item.metadata[:pr_number]
|
|
342
|
+
!pr || (seen[pr] ? false : seen[pr] = true)
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'google/apis/calendar_v3'
|
|
5
|
+
require 'googleauth'
|
|
6
|
+
require 'json'
|
|
7
|
+
require 'stringio'
|
|
8
|
+
require 'time'
|
|
9
|
+
|
|
10
|
+
module Snitcher
|
|
11
|
+
module Sources
|
|
12
|
+
# Reports meeting time from a Google Calendar as WorkItems (one per meeting instance).
|
|
13
|
+
#
|
|
14
|
+
# Two authentication paths, see README "Google Calendar Setup":
|
|
15
|
+
# a service account with domain-wide delegation, which impersonates the requested user
|
|
16
|
+
# and is what makes multi-user reports possible, or a static OAuth2 refresh token, which
|
|
17
|
+
# reads one fixed account. The service account wins when both are configured.
|
|
18
|
+
#
|
|
19
|
+
# Recurring series are expanded into individual instances (single_events: true) and
|
|
20
|
+
# collapsed for display by MeetingsAggregator via the shared recurring_event_id.
|
|
21
|
+
#
|
|
22
|
+
# Filters applied during fetch: meetings that have not finished yet, declined RSVPs, all-day
|
|
23
|
+
# events, solo events (no other attendees), and — when working_hours is configured — time
|
|
24
|
+
# outside business hours.
|
|
25
|
+
class GoogleCalendar < Source
|
|
26
|
+
# The two credential paths ask for different scopes on purpose.
|
|
27
|
+
#
|
|
28
|
+
# Domain-wide delegation matches scopes literally: an admin who authorized
|
|
29
|
+
# `calendar.events` has not thereby authorized `calendar.events.readonly`, and asking for
|
|
30
|
+
# the narrower one fails with `unauthorized_client`. DELEGATED_SCOPE is therefore the
|
|
31
|
+
# read/write scope Workspace admins already grant to calendar-writing apps, so Snitcher
|
|
32
|
+
# works against an existing delegation entry. This source only lists events; narrow the
|
|
33
|
+
# constant to `calendar.events.readonly` where you control the delegation and can add it.
|
|
34
|
+
#
|
|
35
|
+
# OAuth carries the scope its refresh token was granted, so it stays read-only — widening
|
|
36
|
+
# it here would invalidate tokens already issued.
|
|
37
|
+
DELEGATED_SCOPE = 'https://www.googleapis.com/auth/calendar.events'
|
|
38
|
+
OAUTH_SCOPE = 'https://www.googleapis.com/auth/calendar.events.readonly'
|
|
39
|
+
TOKEN_URI = 'https://oauth2.googleapis.com/token'
|
|
40
|
+
DEFAULT_CALENDAR_ID = 'primary'
|
|
41
|
+
MAX_RESULTS = 250 # Google Calendar API per-page maximum
|
|
42
|
+
MAX_PAGES = 20 # safety cap, mirrors other sources
|
|
43
|
+
SERVICE_ACCOUNT_ENV = 'GOOGLE_SERVICE_ACCOUNT_JSON'
|
|
44
|
+
|
|
45
|
+
def initialize(config = {})
|
|
46
|
+
super
|
|
47
|
+
validate_calendar_id!(config[:calendar_id])
|
|
48
|
+
@client_id = Source.presence(config[:client_id]) || Source.env('GOOGLE_CLIENT_ID')
|
|
49
|
+
@client_secret = Source.presence(config[:client_secret]) || Source.env('GOOGLE_CLIENT_SECRET')
|
|
50
|
+
@refresh_token = Source.presence(config[:refresh_token]) || Source.env('GOOGLE_REFRESH_TOKEN')
|
|
51
|
+
@service_account_json = Source.presence(config[:service_account_json]) ||
|
|
52
|
+
Source.env(SERVICE_ACCOUNT_ENV)
|
|
53
|
+
@calendar_id = Source.presence(config[:calendar_id]) || DEFAULT_CALENDAR_ID
|
|
54
|
+
@service = config[:service] # test seam; nil in production
|
|
55
|
+
@now = config[:now] # test seam; nil in production
|
|
56
|
+
@services_by_email = {}
|
|
57
|
+
|
|
58
|
+
warn_both_credentials if service_account? && oauth?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def source_name
|
|
62
|
+
:google_calendar
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def available?
|
|
66
|
+
service_account? || oauth?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def unavailable_reason
|
|
70
|
+
"neither #{SERVICE_ACCOUNT_ENV} nor the GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET + " \
|
|
71
|
+
'GOOGLE_REFRESH_TOKEN trio is set'
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Multi-user reporting needs a service account: the OAuth path reads one fixed account's
|
|
75
|
+
# calendar and cannot honour a requested user_email. Credentials are ENV-only by design
|
|
76
|
+
# (config[:service_account_json] exists for tests), so this reads ENV.
|
|
77
|
+
def self.multi_user_capable?
|
|
78
|
+
!!Source.env(SERVICE_ACCOUNT_ENV)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Reads the requested user's calendar. With a service account the credentials impersonate
|
|
82
|
+
# them; with OAuth there is a single authenticated account, so user_email cannot select a
|
|
83
|
+
# calendar and every requested user sees that one account's meetings.
|
|
84
|
+
#
|
|
85
|
+
# One address per person. A person mapped to several addresses is a configuration error
|
|
86
|
+
# rather than a silent choice of the first one: there is one calendar identity per person,
|
|
87
|
+
# and picking one address at random would report the wrong calendar without saying so.
|
|
88
|
+
def fetch(user_email:, since:, until_time:)
|
|
89
|
+
return [] unless available?
|
|
90
|
+
|
|
91
|
+
emails = Array(user_email)
|
|
92
|
+
validate_one_address!(emails)
|
|
93
|
+
|
|
94
|
+
email = primary_email(emails)
|
|
95
|
+
return [] if email.nil? || email.to_s.strip.empty?
|
|
96
|
+
|
|
97
|
+
cutoff = cutoff_for(until_time)
|
|
98
|
+
# A window entirely in the future holds no finished meetings, and a reversed range is not
|
|
99
|
+
# something to ask Google about.
|
|
100
|
+
return [] if cutoff <= since
|
|
101
|
+
|
|
102
|
+
list_calendar_events(user_email: email, since: since, until_time: cutoff).filter_map do |event|
|
|
103
|
+
next if unfinished?(event, cutoff)
|
|
104
|
+
next if all_day?(event)
|
|
105
|
+
next if declined?(event)
|
|
106
|
+
next if solo?(event)
|
|
107
|
+
|
|
108
|
+
seconds = meeting_seconds(event)
|
|
109
|
+
next unless seconds.positive? # fully outside working hours
|
|
110
|
+
|
|
111
|
+
build_meeting_item(event, seconds)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# --- configuration ---
|
|
118
|
+
|
|
119
|
+
# Checked before Source.presence, which calls strip and would raise NoMethodError on an
|
|
120
|
+
# Array — in the constructor, outside the Orchestrator's per-source rescue, taking down
|
|
121
|
+
# the whole report instead of just this source.
|
|
122
|
+
def validate_calendar_id!(calendar_id)
|
|
123
|
+
return unless calendar_id.is_a?(Array)
|
|
124
|
+
|
|
125
|
+
raise Snitcher::ConfigurationError,
|
|
126
|
+
'sources.google_calendar.calendar_id takes one calendar, not a list.'
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def validate_one_address!(emails)
|
|
130
|
+
return unless emails.size > 1
|
|
131
|
+
|
|
132
|
+
raise Snitcher::ConfigurationError,
|
|
133
|
+
'google_calendar accepts one address per person. ' \
|
|
134
|
+
"sources.google_calendar.emails maps #{emails.first} to #{emails.size} addresses."
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# --- credentials ---
|
|
138
|
+
|
|
139
|
+
def service_account?
|
|
140
|
+
!!@service_account_json
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def oauth?
|
|
144
|
+
!!(@client_id && @client_secret && @refresh_token)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The service account takes precedence: unlike the OAuth token, it honours user_email.
|
|
148
|
+
# Warn so a configured refresh token is not mistaken for the credential in use.
|
|
149
|
+
def warn_both_credentials
|
|
150
|
+
warn "[google_calendar] both #{SERVICE_ACCOUNT_ENV} and GOOGLE_REFRESH_TOKEN are set; " \
|
|
151
|
+
'using the service account.'
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The authorized service for one user, cached per email. With a service account the
|
|
155
|
+
# credentials impersonate that user; with OAuth there is a single authenticated account
|
|
156
|
+
# and every user shares it.
|
|
157
|
+
def service_for_user(user_email)
|
|
158
|
+
return @service if @service # injected fake: shared across users by design
|
|
159
|
+
|
|
160
|
+
@services_by_email[user_email] ||= build_service(user_email)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def build_service(user_email)
|
|
164
|
+
svc = Google::Apis::CalendarV3::CalendarService.new
|
|
165
|
+
svc.authorization = service_account? ? impersonated_credentials(user_email) : oauth_credentials
|
|
166
|
+
svc
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The key is parsed once. #duplicate carries `sub` as a first-class option and drops the
|
|
170
|
+
# base credential's cached tokens, so no token leaks between impersonated users.
|
|
171
|
+
def impersonated_credentials(user_email)
|
|
172
|
+
base_service_account_credentials.duplicate(sub: user_email)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def base_service_account_credentials
|
|
176
|
+
@base_service_account_credentials ||=
|
|
177
|
+
Google::Auth::ServiceAccountCredentials.make_creds(
|
|
178
|
+
json_key_io: StringIO.new(decoded_service_account_key), scope: DELEGATED_SCOPE
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Decoded here rather than left to googleauth so a misconfigured variable reports what to
|
|
183
|
+
# fix, instead of surfacing an OpenSSL or JSON error from inside the gem.
|
|
184
|
+
#
|
|
185
|
+
# The hint avoids the base64 command on purpose: GNU base64 wraps its output at 76
|
|
186
|
+
# characters, and strict_decode64 rejects newlines. The Ruby one-liner is portable.
|
|
187
|
+
def decoded_service_account_key
|
|
188
|
+
decoded = Base64.strict_decode64(@service_account_json)
|
|
189
|
+
JSON.parse(decoded)
|
|
190
|
+
decoded
|
|
191
|
+
rescue ArgumentError
|
|
192
|
+
raise Snitcher::ConfigurationError,
|
|
193
|
+
"#{SERVICE_ACCOUNT_ENV} is not valid base64, or contains line breaks. Encode the " \
|
|
194
|
+
"key file: ruby -rbase64 -e 'print Base64.strict_encode64(File.binread(ARGV[0]))' key.json"
|
|
195
|
+
rescue JSON::ParserError
|
|
196
|
+
raise Snitcher::ConfigurationError,
|
|
197
|
+
"#{SERVICE_ACCOUNT_ENV} does not decode to JSON. Re-encode the service account key file."
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def oauth_credentials
|
|
201
|
+
Google::Auth::UserRefreshCredentials.new(
|
|
202
|
+
client_id: @client_id, client_secret: @client_secret,
|
|
203
|
+
refresh_token: @refresh_token, scope: OAUTH_SCOPE, token_credential_uri: TOKEN_URI
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# --- transport (test seam: override via config[:service] or stub this method) ---
|
|
208
|
+
|
|
209
|
+
# calendar_id applies in both credential modes. Left at the default, every reported
|
|
210
|
+
# person reads their own calendar, because `primary` resolves against the impersonated
|
|
211
|
+
# user. Set, every reported person reads that one calendar — each authenticated as
|
|
212
|
+
# themselves, so Google still checks access per person.
|
|
213
|
+
def list_calendar_events(user_email:, since:, until_time:)
|
|
214
|
+
service = service_for_user(user_email)
|
|
215
|
+
calendar_id = @calendar_id
|
|
216
|
+
events = []
|
|
217
|
+
page_token = nil
|
|
218
|
+
pages = 0
|
|
219
|
+
loop do
|
|
220
|
+
result = service.list_events(
|
|
221
|
+
calendar_id,
|
|
222
|
+
single_events: true, # expand recurring series into instances
|
|
223
|
+
order_by: 'startTime',
|
|
224
|
+
# getutc, not utc: Time#utc converts in place, and these two objects belong to the
|
|
225
|
+
# caller — the CLI hands the same instances to the presenter and to anomaly
|
|
226
|
+
# detection afterwards. Mutating them shifted the reported period a day back in
|
|
227
|
+
# any timezone ahead of UTC.
|
|
228
|
+
time_min: since.getutc.iso8601,
|
|
229
|
+
time_max: until_time.getutc.iso8601,
|
|
230
|
+
max_results: MAX_RESULTS,
|
|
231
|
+
page_token: page_token
|
|
232
|
+
)
|
|
233
|
+
events.concat(result.items || [])
|
|
234
|
+
page_token = result.next_page_token
|
|
235
|
+
pages += 1
|
|
236
|
+
break if page_token.nil? || pages >= MAX_PAGES
|
|
237
|
+
end
|
|
238
|
+
events
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# --- the window ---
|
|
242
|
+
|
|
243
|
+
# The window never reaches past the current moment. A report whose --to is today asks for the
|
|
244
|
+
# whole day, and Google answers with the meetings still ahead of us; counting those reports
|
|
245
|
+
# time that has not been spent.
|
|
246
|
+
#
|
|
247
|
+
# No timezone handling here on purpose: Time comparison is by moment, not by wall-clock
|
|
248
|
+
# fields, so this is correct whatever zone the process, the calendar and the event use.
|
|
249
|
+
def cutoff_for(until_time)
|
|
250
|
+
[until_time, @now || Time.now].min
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# --- filters ---
|
|
254
|
+
|
|
255
|
+
# Google returns events that overlap the window, so a meeting running right now comes back
|
|
256
|
+
# even with time_max at the cut-off. A meeting counts once it is over — reporting the elapsed
|
|
257
|
+
# part would change the same report every few minutes while the meeting runs.
|
|
258
|
+
#
|
|
259
|
+
# A missing end time falls through: #meeting_seconds returns 0 for it and the seconds check
|
|
260
|
+
# in #fetch drops it.
|
|
261
|
+
def unfinished?(event, cutoff)
|
|
262
|
+
finish = to_time(event.end&.date_time)
|
|
263
|
+
!finish.nil? && finish > cutoff
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def all_day?(event)
|
|
267
|
+
event.start&.date && event.start.date_time.nil?
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# The owning user's RSVP. Uses Google's `self` flag (no email matching needed).
|
|
271
|
+
def declined?(event)
|
|
272
|
+
me = self_attendee(event)
|
|
273
|
+
me && me.response_status == 'declined'
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Personal reminders/focus blocks: no attendees, or only the owner.
|
|
277
|
+
def solo?(event)
|
|
278
|
+
others = (event.attendees || []).reject(&:self)
|
|
279
|
+
others.empty?
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def self_attendee(event)
|
|
283
|
+
(event.attendees || []).find(&:self)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# --- mapping ---
|
|
287
|
+
|
|
288
|
+
def meeting_seconds(event)
|
|
289
|
+
start = to_time(event.start&.date_time)
|
|
290
|
+
finish = to_time(event.end&.date_time)
|
|
291
|
+
return 0 unless start && finish
|
|
292
|
+
|
|
293
|
+
WorkingHoursCalculator.seconds_between(start, finish, @working_hours)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def build_meeting_item(event, seconds)
|
|
297
|
+
finish = to_time(event.end&.date_time)
|
|
298
|
+
build_work_item(
|
|
299
|
+
external_id: event.id,
|
|
300
|
+
title: event.summary || '(no title)',
|
|
301
|
+
completed_at: finish,
|
|
302
|
+
time_spent_seconds: seconds,
|
|
303
|
+
time_source: :calculated,
|
|
304
|
+
url: event.html_link,
|
|
305
|
+
metadata: {
|
|
306
|
+
start: to_time(event.start&.date_time)&.iso8601,
|
|
307
|
+
end: finish&.iso8601,
|
|
308
|
+
attendees_count: (event.attendees || []).size,
|
|
309
|
+
organizer: event.organizer&.email,
|
|
310
|
+
recurring: !event.recurring_event_id.nil?,
|
|
311
|
+
recurring_event_id: event.recurring_event_id
|
|
312
|
+
}
|
|
313
|
+
)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Google SDK returns DateTime; normalize to Time (UTC) for WorkingHoursCalculator.
|
|
317
|
+
#
|
|
318
|
+
# Only date_time is read, not the separate time_zone field next to it. In an events.list
|
|
319
|
+
# response date_time carries the event's own offset, so the moment is unambiguous; time_zone
|
|
320
|
+
# is there to expand recurrence rules, which the server did for us (single_events: true).
|
|
321
|
+
def to_time(value)
|
|
322
|
+
return nil if value.nil?
|
|
323
|
+
|
|
324
|
+
value.respond_to?(:to_time) ? value.to_time.utc : value
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|