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