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
data/README.md ADDED
@@ -0,0 +1,685 @@
1
+ # snitcher_reports
2
+
3
+ > Embeddable multi-source work-report generator — Jira, ClickUp, Linear, Git, Google Calendar.
4
+
5
+ A host application builds one `Snitcher::Client` from a config file plus **organization-level**
6
+ credentials (read from the host's `ENV`), then generates a work report **per user** — selecting
7
+ the user on each call. This is the multi-user path: one set of org credentials, many users of
8
+ that organization.
9
+
10
+ > **On the name.** The gem is named `snitcher_reports` because `snitcher` on RubyGems is an
11
+ > unrelated Dead Man's Snitch client. The Ruby namespace and require path are unchanged:
12
+ > `require "snitcher"` → `Snitcher::Client`.
13
+
14
+ ## What it does
15
+
16
+ Aggregates completed work across trackers into a unified per-user report, so a host application
17
+ does not have to talk to five APIs and reconcile their models. You get back a plain `Hash` — and,
18
+ when you ask for a document, a Markdown or HTML file written to disk with its path in that Hash.
19
+
20
+ ### Data sources
21
+
22
+ | Source | What it contributes |
23
+ |---|---|
24
+ | **Jira** | completed issues, tracked time |
25
+ | **Git** | commits from a local checkout |
26
+ | **ClickUp** | closed tasks, tracked time |
27
+ | **Linear** | completed issues, tracked time |
28
+ | **Google Calendar** | meeting time, reported separately from ticket time |
29
+
30
+ Sources without credentials are skipped automatically — the skip is recorded in
31
+ `failed_sources` (see below), so it never passes as a quiet week with nothing to report.
32
+ By default every source above is requested, so a host that only uses, say, Git gets a
33
+ `failed_sources` entry for each of the other four on every report. That is expected — "never
34
+ configured" and "configured but broken" read identically in `failed_sources`, since neither
35
+ has items to show — but if it is noise rather than signal for your setup, pass the sources
36
+ you actually use explicitly (`sources: "git"`, or a comma-separated list) instead of relying
37
+ on the default.
38
+
39
+ ### Output formats
40
+
41
+ - **Hash** — data to compute with. The primary form; `Client#work_report` returns
42
+ the Hash directly.
43
+ - **Markdown** — rich `.md` report with header, summary and per-source sections.
44
+ - **HTML** — self-contained page with responsive design, stat cards and per-source tables;
45
+ includes "Print to PDF" buttons.
46
+
47
+ The two document formats are written to disk and the call still returns the Hash, now
48
+ carrying the file's path. See [Rendering a report](#rendering-a-report).
49
+
50
+ ## Install
51
+
52
+ Add the gem to your host application's `Gemfile`:
53
+
54
+ ```ruby
55
+ gem "snitcher_reports"
56
+ ```
57
+
58
+ or install it directly with `gem install snitcher_reports`.
59
+
60
+ ## Configuration
61
+
62
+ Static, non-secret configuration (status mappings, project/space filters, the git repo path
63
+ and the work-email → git-email map) lives in a YAML file you point `config_path:` at. It uses
64
+ the schema documented under [`.snitcher.yml` format](#snitcheryml-format):
65
+
66
+ ```yaml
67
+ # config/snitcher.yml
68
+ version: 1
69
+ sources:
70
+ git:
71
+ # Absolute path to a local checkout of the repository to read commits from.
72
+ repo_path: /srv/app/repo
73
+ # Map each work email to the address that user commits under.
74
+ # Falls back to the requested user_email when there is no entry.
75
+ emails:
76
+ alice@corp.com: alice.private@gmail.com
77
+ bob@corp.com: bob@corp.com
78
+ ```
79
+
80
+ The file is optional and **backward compatible** — if it does not exist, every source falls
81
+ back to its built-in defaults.
82
+
83
+ ### Credentials
84
+
85
+ Secrets come from the **host's** `ENV` (org-level credentials), not the config file.
86
+ Every variable may be given a `SNITCHER_` prefix, which is the recommended form when
87
+ embedding: a host application's `.env` holds credentials for several libraries, and the
88
+ prefix says at a glance which ones belong here.
89
+
90
+ ```bash
91
+ # ClickUp
92
+ SNITCHER_CLICKUP_API_TOKEN=...
93
+ SNITCHER_CLICKUP_TEAM_ID=...
94
+ # Jira
95
+ SNITCHER_JIRA_URL=https://your-org.atlassian.net
96
+ SNITCHER_JIRA_USERNAME=service-account@corp.com
97
+ SNITCHER_JIRA_API_TOKEN=...
98
+ # Linear
99
+ SNITCHER_LINEAR_API_KEY=...
100
+ ```
101
+
102
+ The unprefixed names (`CLICKUP_API_TOKEN`, `JIRA_URL`, …) remain fully supported. When both
103
+ are present the prefixed one wins, so a leftover bare variable cannot silently shadow an
104
+ explicit `SNITCHER_` declaration. See `.env.example` for the full list.
105
+
106
+ | Source | Variables |
107
+ |---|---|
108
+ | Jira | `JIRA_URL`, `JIRA_USERNAME`, `JIRA_API_TOKEN` |
109
+ | Linear | `LINEAR_API_KEY` |
110
+ | ClickUp | `CLICKUP_API_TOKEN`, `CLICKUP_TEAM_ID` |
111
+ | Google Calendar | `GOOGLE_SERVICE_ACCOUNT_JSON` (several users), or `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REFRESH_TOKEN` (one user) |
112
+ | Git | None (uses the current directory; override with `sources.git.repo_path`) |
113
+
114
+ Jira is worth one note: Snitcher calls the Jira Cloud REST API v3 directly, authenticating
115
+ with `JIRA_USERNAME` + `JIRA_API_TOKEN` as HTTP Basic. **Jira Data Center is not
116
+ supported** — only `/rest/api/3` on Jira Cloud.
117
+
118
+ For tickets with no worklogs, Snitcher reads time from the status history, which means it
119
+ has to decide which statuses count as "in progress". It resolves that in three steps:
120
+
121
+ 1. the names under `sources.jira.*_statuses` in `.snitcher.yml`, if the status is listed
122
+ there — an explicit decision, so it wins;
123
+ 2. the instance's own status catalogue (`/rest/api/3/status`), which carries Jira's
124
+ language-independent `statusCategory` and therefore works on non-English instances;
125
+ 3. a small set of hardcoded English names (`In Progress`, `Done`, `To Do`, …), used only
126
+ when neither of the above answers.
127
+
128
+ Step 2 means most instances need no `*_statuses` configuration at all. Configure it when
129
+ your classification differs from Jira's own — for example to count `In Review` as active
130
+ work when Jira files it under "To Do", or for a status Jira leaves in "No Category".
131
+
132
+ `require "snitcher"` does **not** read a `.env` file — the embedded core takes the host
133
+ application's `ENV` as-is.
134
+
135
+ ### `.snitcher.yml` format
136
+
137
+ ```yaml
138
+ version: 1
139
+ sources:
140
+ clickup:
141
+ # Optional: limit reports to specific ClickUp spaces.
142
+ # Omit or leave empty to include all spaces. Names are case-sensitive.
143
+ # This also limits where archived lists are looked for.
144
+ spaces:
145
+ - Engineering
146
+ done_statuses:
147
+ - "Zamkniete"
148
+ - "Done"
149
+ in_progress_statuses:
150
+ - "W trakcie"
151
+ todo_statuses:
152
+ - "Do zrobienia"
153
+ jira:
154
+ # Optional: limit reports to specific JIRA projects
155
+ projects:
156
+ - SCRUM
157
+ done_statuses:
158
+ - "Gotowe"
159
+ in_progress_statuses:
160
+ - "W realizacji"
161
+ todo_statuses:
162
+ - "Do zrobienia"
163
+ linear:
164
+ done_statuses:
165
+ - "Done"
166
+ in_progress_statuses:
167
+ - "In Progress"
168
+ todo_statuses:
169
+ - "Todo"
170
+ git:
171
+ # Optional: default git commit email used when work_report is called without
172
+ # a user_email. Ignored once user_email is supplied.
173
+ user: your.personal@email.com
174
+ # Optional: per-user git identities. Maps each work email (as passed in
175
+ # user_email: / user_emails:) to the git commit email(s) that person authors
176
+ # under. A value may be a single email or a list.
177
+ emails:
178
+ alice@corp.com: alice.personal@example.com
179
+ bob@corp.com:
180
+ - bob@corp.com
181
+ - bob.noreply@users.noreply.github.com
182
+ # Optional: path to the git repository (default: current directory).
183
+ repo_path: /path/to/your/project
184
+ # Optional: seconds any single git command may run before it is killed and the
185
+ # source is reported as failed (default: 30). Raise it for very large histories.
186
+ timeout: 30
187
+
188
+ # Optional: filter calculated time to business hours.
189
+ # Applies to Jira, ClickUp, Linear and Google Calendar meeting time.
190
+ # Omit this block to keep raw wall-clock time calculation.
191
+ # Note: start_hour must be earlier than end_hour (night shifts not supported); equal
192
+ # hours are rejected too. A shared block that breaks this is ignored with a warning,
193
+ # leaving time unfiltered; a person's block falls back to the shared one.
194
+ working_hours:
195
+ timezone: "Europe/Warsaw" # IANA timezone name
196
+ start_hour: "09:00" # Work day start (HH:MM, must be quoted)
197
+ end_hour: "17:00" # Work day end (HH:MM, must be quoted)
198
+ work_days: [1, 2, 3, 4, 5] # Mon-Fri (0=Sun, 1=Mon, ..., 6=Sat)
199
+ holidays_region: "pl" # ISO country code for public holiday exclusion (optional)
200
+
201
+ # Optional: per-person overrides, keyed by the work email reports are requested for
202
+ # (user_email: / user_emails:). A person inherits every key they leave out, and anyone
203
+ # not listed here uses the block above unchanged.
204
+ people:
205
+ bob@corp.com:
206
+ timezone: "America/New_York"
207
+ holidays_region: "us"
208
+ cara@corp.com:
209
+ end_hour: "15:00"
210
+
211
+ # Optional: directory that saved markdown/html reports are written to.
212
+ # Default: ./snitcher_reports (see "Where the file lands").
213
+ output_dir: "tmp/snitcher_reports"
214
+ ```
215
+
216
+ Status names are language-specific (Polish, German, …), so the values above are illustrative —
217
+ replace them with the names your workspace actually uses. A `.snitcher.yml.example` ships with
218
+ the gem as a starting point.
219
+
220
+ `working_hours.people` resolves per the work email a report is requested for (`user_email:` /
221
+ `user_emails:`). A person not listed there uses the shared block above unchanged; a person who is
222
+ listed inherits every key they leave out from it. The resolved hours apply on both sides: the
223
+ source-side time calculations (Jira changelog, Linear cycle time, ClickUp status history, Google
224
+ Calendar meeting clamping) and the anomaly-side availability, expected working days and holiday
225
+ exclusion.
226
+
227
+ A malformed address under `sources.<name>.user` or `sources.<name>.emails` is warned about on
228
+ stderr and dropped; the rest of the config still loads. An `emails` entry left with no valid
229
+ address falls back to filtering by the work email itself, and a `user` left with none leaves
230
+ that source out of a report run without `user_email:`, listed in `failed_sources`.
231
+
232
+ ## Usage
233
+
234
+ ```ruby
235
+ require "snitcher"
236
+
237
+ snitcher = Snitcher.new(config_path: "config/snitcher.yml")
238
+
239
+ # One user:
240
+ snitcher.work_report(user_email: "alice@corp.com", from: "2026-07-01", to: "2026-07-14")
241
+ snitcher.work_report(user_email: "bob@corp.com", from: "2026-07-01", to: "2026-07-14")
242
+
243
+ # Many users in one call — reuses one Orchestrator, isolates per-user failures:
244
+ reports = snitcher.work_reports(
245
+ user_emails: ["alice@corp.com", "bob@corp.com"],
246
+ from: "2026-07-01", to: "2026-07-14"
247
+ )
248
+ # => { "alice@corp.com" => { metadata:, anomalies:, items:, summary: },
249
+ # "bob@corp.com" => { ... } }
250
+ # A failed user: { "bob@corp.com" => { error: "...", error_class: "..." } }
251
+ ```
252
+
253
+ Each call returns the same `Hash`:
254
+
255
+ ```ruby
256
+ {
257
+ metadata: { ... }, # user, date range, counts
258
+ anomalies: [ ... ], # detected anomalies (empty when include_anomalies: false)
259
+ items: [ ... ], # the work items
260
+ summary: { ... } # aggregate totals
261
+ }
262
+ ```
263
+
264
+ ### Check `failed_sources` before you trust the numbers
265
+
266
+ A source that raises while fetching is logged and skipped so the rest of the report still
267
+ renders; a source that was never available to start with — missing credentials, a
268
+ misconfigured path — is skipped the same way, before it ever gets to fetch anything. Either
269
+ way, its items are missing everywhere — items, totals and anomaly signals alike — and an
270
+ outage looks exactly like a quiet week. The key is present **only** when something failed, so
271
+ its absence means every requested source answered:
272
+
273
+ ```ruby
274
+ report = snitcher.work_report(user_email: "alice@corp.com", from: "2026-03-01")
275
+
276
+ if report[:metadata][:failed_sources]
277
+ # [{ source: "linear", error: "Snitcher::Error: Authentication required" },
278
+ # { source: "git", error: "sources.git.repo_path (/srv/app/repo) is not a git checkout" }]
279
+ # error is a free-form sentence, not always "ExceptionClass: message" — don't parse its shape.
280
+ # Tell the user the report is partial, or retry — do not present it as complete.
281
+ end
282
+ ```
283
+
284
+ A source skipped for want of an identity lands here too: a `work_report` without `user_email:`
285
+ whose `sources.<name>.user` holds no valid address reports that source as failed.
286
+
287
+ Saved Markdown and HTML reports carry no such marker: they are the artefact a reader
288
+ receives, and a broken credential is the operator's problem, not theirs. Surface the fact in
289
+ your own UI if a person needs to see it.
290
+
291
+ ### Parameters
292
+
293
+ - `user_email:` (`work_report`, optional) / `user_emails:` (`work_reports`, required) — the
294
+ user(s) to report on, resolved to a git commit email via `sources.git.emails` and falling
295
+ back to the value itself. When `user_email:` is omitted, `work_report` falls back to the
296
+ per-source default identity (`sources.<name>.user`) from the config file.
297
+ - `from:` (required), `to:` (optional, defaults to today) — `YYYY-MM-DD`, `to` inclusive.
298
+ - `sources:` (optional) — a Symbol/String, an Array, or a comma-separated String. `nil`,
299
+ `""`, or `"all"` select every multi-user source (git, jira, clickup, linear). An unknown
300
+ source raises `ArgumentError`.
301
+ - `include_anomalies:` (optional, default `true`).
302
+ - `format:` (optional, default `:hash`) — see [Rendering a report](#rendering-a-report). A
303
+ Symbol or String; an unknown value raises `ArgumentError`.
304
+ - `narrative:` (optional) — prose rendered as an Overview section by the `:markdown` and
305
+ `:html` formats.
306
+ - `output_dir:` / `output_path:` (optional) — where a saved document goes; see
307
+ [Where the file lands](#where-the-file-lands).
308
+
309
+ ### Thread safety
310
+
311
+ The `Client` loads the configuration once at construction. Each `work_report` call builds
312
+ its own orchestrator, so separate `work_report` calls are independent and safe to run from
313
+ different threads. `work_reports` deliberately reuses a **single** orchestrator across the
314
+ whole batch (to avoid re-initializing sources per user) and runs its users **sequentially** —
315
+ do not parallelize a single `work_reports` call, as its sources hold per-batch state.
316
+
317
+ ## Rendering a report
318
+
319
+ Pass `format:` to the same call. `Client` already knows the period, the working-hours
320
+ config and which sources failed — everything the presenters need — so there is nothing to
321
+ assemble by hand:
322
+
323
+ ```ruby
324
+ report = snitcher.work_report(user_email: "alice@corp.com", from: "2026-07-01",
325
+ to: "2026-07-14", format: :html)
326
+
327
+ report[:metadata][:report_path]
328
+ # => "/srv/app/snitcher_reports/alice-at-corp.com_2026-07-01_2026-07-14.html"
329
+ ```
330
+
331
+ The three formats fall into two kinds:
332
+
333
+ | `format:` | Returns | On disk |
334
+ |---|---|---|
335
+ | `:hash` (default) | the fact payload as a `Hash` | — |
336
+ | `:markdown` | the payload, plus `metadata[:report_path]` | rich `.md` document |
337
+ | `:html` | the payload, plus `metadata[:report_path]` | self-contained page |
338
+
339
+ **A document format writes the file and still returns the payload.** Markdown and HTML are
340
+ artefacts a person opens, not values a program computes with; handing back a
341
+ multi-kilobyte String would leave every host to invent its own filename. So the gem writes
342
+ it, and `metadata[:report_path]` (with `metadata[:report_format]`) says where. The key is
343
+ present only when something was actually written, so its absence means "nothing was saved".
344
+
345
+ The HTML output is self-contained — the logo is embedded as base64, so the page needs no
346
+ external assets. An unknown format raises `ArgumentError` **before** any source is queried,
347
+ so a typo costs no API calls.
348
+
349
+ **There is no JSON format, on purpose.** The payload is a plain `Hash` with symbol keys, and
350
+ serializing it is one call the host already knows how to make — `payload.to_json`,
351
+ `JSON.pretty_generate(payload)`, `Oj.dump(payload)`, or `render json: payload` in a Rails
352
+ controller. Owning a `:json` format would only mean one value of `format:` returning a
353
+ `String` while the rest return a `Hash`, which every caller then has to special-case. Note
354
+ that JSON turns the symbol keys into strings, so a round trip through JSON does not hand back
355
+ an identical `Hash`.
356
+
357
+ ### Where the file lands
358
+
359
+ In order of precedence:
360
+
361
+ 1. `output_path:` on the call — an exact file path, `work_report` only. Missing
362
+ directories are created.
363
+ 2. `output_dir:` on the call — a directory; the filename is generated.
364
+ 3. `output_dir:` in `.snitcher.yml` — the same, for every call.
365
+ 4. `./snitcher_reports/` — the fallback. Deliberately a subdirectory, not the working
366
+ directory itself: that is the host application's root, not the gem's to scatter files
367
+ across.
368
+
369
+ Generated names are `<user>_<from>_<to>.<ext>` (`alice-at-corp.com_2026-07-01_2026-07-14.html`),
370
+ so regenerating the same report for the same period **overwrites** it rather than piling
371
+ up copies. Pass `output_path:` if you want a name of your own.
372
+
373
+ `work_reports` takes `format:` and `output_dir:` too, and writes one document per user —
374
+ named after that user, since one shared path would have the last user overwrite everyone
375
+ before them. There is no `output_path:` counterpart for that reason:
376
+
377
+ ```ruby
378
+ reports = snitcher.work_reports(user_emails: %w[alice@corp.com bob@corp.com],
379
+ from: "2026-07-01", format: :html,
380
+ output_dir: "tmp/reports")
381
+
382
+ reports.each do |email, report|
383
+ next puts "#{email}: #{report[:error]}" if report[:error]
384
+
385
+ puts "#{email}: #{report[:metadata][:report_path]}"
386
+ end
387
+ ```
388
+
389
+ `Snitcher::Report` remains public for the case where you already hold work items from
390
+ somewhere other than a `work_report` call. It only renders — writing is `ReportWriter`'s
391
+ job — and building it yourself means building its `config` yourself: omit
392
+ `since:`/`until_time:` and you lose the report header and every period-aware anomaly rule.
393
+
394
+ ### Narrative summaries
395
+
396
+ Pass `narrative:` and the Markdown and HTML formats render it as an Overview section:
397
+
398
+ ```ruby
399
+ snitcher.work_report(user_email: "alice@corp.com", from: "2026-07-01",
400
+ format: :markdown, narrative: my_summary_text)
401
+ ```
402
+
403
+ For a batch, give `work_reports` a Hash keyed by the requested email — one prose summary per
404
+ person — or a single String to caption every report:
405
+
406
+ ```ruby
407
+ snitcher.work_reports(user_emails: %w[alice@corp.com bob@corp.com], from: "2026-07-01",
408
+ format: :markdown,
409
+ narrative: { "alice@corp.com" => "Shipped the parser rewrite." })
410
+ ```
411
+
412
+ The gem ships **no** LLM integration. The slot is there so a host that already has a model
413
+ available can generate the prose with it and pass the string in; without it the section is
414
+ simply omitted.
415
+
416
+ ## Google Calendar Setup
417
+
418
+ The Google Calendar source reports **meeting time** — how many hours went into meetings during
419
+ the report period. Meetings appear in a dedicated **Meetings** section (recurring series
420
+ collapsed to one row each) and are kept separate from tracked ticket time.
421
+
422
+ There are two ways to authenticate. Pick one.
423
+
424
+ | | Service account | OAuth2 |
425
+ |---|---|---|
426
+ | Reports on | any number of users | one user |
427
+ | Whose calendar | each reported user's own | the account you signed in with |
428
+ | Setup needs | Google Cloud + Workspace Admin access | a Google account |
429
+ | Variable | `GOOGLE_SERVICE_ACCOUNT_JSON` | the three `GOOGLE_*` values below |
430
+
431
+ If both are configured, the service account is used and Snitcher prints a warning.
432
+
433
+ ### Service account (several users)
434
+
435
+ To report on more than one person, use a Google Cloud service account with domain-wide
436
+ delegation.
437
+
438
+ 1. In the [Google Cloud Console](https://console.cloud.google.com/), create or pick a project.
439
+ Under **APIs & Services → Library**, enable the **Google Calendar API**. The API is free.
440
+ 2. Create a service account and a JSON key for it.
441
+ 3. In the Google Workspace Admin console, go to **Security → Access and data control → API
442
+ controls → Domain-wide delegation**. Add the service account's numeric client ID (it is in
443
+ the key file) and authorize the scope
444
+ `https://www.googleapis.com/auth/calendar.events.readonly`.
445
+ This step is what makes impersonation work. Most setup problems come from skipping it.
446
+ 4. **Put the key in the host's environment, base64-encoded.** The key file is multi-line JSON,
447
+ with braces, quotes and `\n` sequences inside the private key. An environment variable must
448
+ be one line, so the file cannot go there as it is. Base64 turns it into a single line of
449
+ plain characters.
450
+
451
+ Run this from the directory that holds your `.env`, with the path to your own key file:
452
+
453
+ ```bash
454
+ echo "GOOGLE_SERVICE_ACCOUNT_JSON=$(ruby -rbase64 -e 'print Base64.strict_encode64(File.binread(ARGV[0]))' key.json)" >> .env
455
+ ```
456
+
457
+ It appends one finished line to `.env`. There is nothing else to edit. You can then move
458
+ `key.json` out of the project or delete it, because Snitcher reads only the variable.
459
+
460
+ Each reported user's own `primary` calendar is read, so
461
+ `work_reports(user_emails: ["a@corp.com", "b@corp.com"])` gives one report per person. Set
462
+ `sources.google_calendar.calendar_id` and every reported user reads that one calendar instead.
463
+ Each user is still authenticated as themselves, so Google checks their access.
464
+
465
+ `sources.google_calendar.emails` must give one address per person. Two addresses for one
466
+ person is an error: the calendar section is skipped with a warning and the rest of the report
467
+ still renders.
468
+
469
+ ### OAuth2 (one user)
470
+
471
+ OAuth2 reads the calendar of one Google account. The requested `user_email:` does not change
472
+ which calendar is read. It needs a one-time bootstrap to obtain a long-lived `refresh_token`.
473
+ You only do this once per machine.
474
+
475
+ 1. **Create a Google Cloud project and enable the API.** Go to the
476
+ [Google Cloud Console](https://console.cloud.google.com/), create (or pick) a project, then under
477
+ **APIs & Services → Library** enable the **Google Calendar API**.
478
+ 2. **Create an OAuth 2.0 Client ID.** Under **APIs & Services → Credentials → Create Credentials →
479
+ OAuth client ID**, choose application type **Web application** (not "Desktop app" — the OAuth
480
+ Playground used in step 3 needs a registered redirect URI, which Desktop clients do not support).
481
+ Under **Authorized redirect URIs** click **Add URI** and enter exactly:
482
+ ```
483
+ https://developers.google.com/oauthplayground
484
+ ```
485
+ (no trailing slash, and it goes in **Authorized redirect URIs** — *not* "Authorized JavaScript
486
+ origins", which rejects paths). Save, then note the generated **Client ID** and **Client secret**.
487
+ If prompted, configure the OAuth consent screen first and add your own Google account as a test user.
488
+
489
+ > **Token lifetime — important.** A consent screen left in **Testing** mode issues refresh tokens
490
+ > that **expire after 7 days**, after which the report stops working until you repeat step 3. For a
491
+ > token that does not expire, publish the app: **APIs & Services → OAuth consent screen** (or
492
+ > **Google Auth Platform → Audience**) → **Publish app** → status **In production**. For a read-only
493
+ > calendar scope and personal use, publishing does not require Google's app-verification review.
494
+ > After publishing, re-run step 3 to mint a fresh, durable token.
495
+ 3. **Obtain a `refresh_token`** for the read-only scope
496
+ `https://www.googleapis.com/auth/calendar.events.readonly`. The simplest path is the
497
+ [OAuth 2.0 Playground](https://developers.google.com/oauthplayground/):
498
+ 1. Click the gear icon (⚙, top right) → check **Use your own OAuth credentials** and paste your
499
+ Client ID and Client secret from step 2.
500
+ 2. In **Step 1 (Select & authorize APIs)**, paste the scope
501
+ `https://www.googleapis.com/auth/calendar.events.readonly` into the input box and click
502
+ **Authorize APIs**. Sign in with the Google account whose calendar you want to report on and
503
+ grant access.
504
+ 3. In **Step 2**, click **Exchange authorization code for tokens**. Copy the **Refresh token** value.
505
+
506
+ > For the Playground to return a refresh token, your client must be authorized for it. If you ever
507
+ > need to re-issue the token, revoke the app's access at
508
+ > [myaccount.google.com/permissions](https://myaccount.google.com/permissions) and repeat steps i–iii.
509
+ >
510
+ > **Hitting `Error 400: redirect_uri_mismatch`?** The Client ID pasted into the Playground (⚙) must
511
+ > be the *same* client whose **Authorized redirect URIs** include `https://developers.google.com/oauthplayground`
512
+ > (see step 2). A common cause is editing the redirect URI on one client while the Playground still
513
+ > holds a different client's ID. Re-check both, then refresh the Playground page; newly added URIs
514
+ > can also take a minute to propagate.
515
+ 4. **Put the three values in the host's environment:**
516
+ ```bash
517
+ GOOGLE_CLIENT_ID=... # from step 2
518
+ GOOGLE_CLIENT_SECRET=... # from step 2
519
+ GOOGLE_REFRESH_TOKEN=... # from step 3
520
+ ```
521
+ The report reads the authenticated account's `primary` calendar. To read a different calendar, set
522
+ `sources.google_calendar.calendar_id` in the config file (the calendar's ID, e.g. an email-like
523
+ address from Google Calendar settings). On this path `user_email:` does **not** select a calendar —
524
+ every reported user gets the same account's meetings.
525
+
526
+ To cover several people without a service account, run one report per person, each with that
527
+ person's own `GOOGLE_REFRESH_TOKEN` in the environment.
528
+ 5. **Filtering rules.** These events are excluded automatically: meetings that have not finished yet,
529
+ declined invitations, all-day events, and solo events (no other attendees — e.g. personal focus
530
+ blocks). When a `working_hours` block is configured, meeting time outside business hours is dropped
531
+ and partial overlaps are clamped. Meeting time is reported separately from tracked ticket time, so
532
+ it never inflates the "Total tracked time" / "Hours Logged" figures.
533
+ 6. **A report that includes today stops at the current moment.** Meetings scheduled for later today
534
+ are not counted, and neither is a meeting you are in right now — it appears once it is over. The
535
+ reported period still shows the full range you asked for, so a report generated at 11:00 for today
536
+ says `2026-08-21`, not `2026-08-21 11:00`. A report for a range that lies entirely in the future
537
+ returns no meetings.
538
+
539
+ ### Multi-user needs a service account
540
+
541
+ Whether `google_calendar` can serve a multi-user report depends on the credentials you
542
+ configured.
543
+
544
+ With `GOOGLE_SERVICE_ACCOUNT_JSON` set, it works like every other source: each requested
545
+ `user_email` is impersonated and gets their own calendar. It is part of the default source set.
546
+
547
+ With only an OAuth token, it reads one fixed account and cannot honour a requested
548
+ `user_email`. It is then left out of the default source set, and a host that asks for it by
549
+ name (`sources: "google_calendar"`) gets a warning on stderr and no calendar section.
550
+
551
+ ## Git source details
552
+
553
+ The Git source parses the local commit history of the checkout at `sources.git.repo_path`
554
+ (default: the current directory) to find work items. It generates **commit links** by resolving
555
+ the `origin` remote URL into a browsable HTTPS URL (supports SCP-style SSH, `ssh://`, and HTTPS
556
+ remotes). Repos without an `origin` remote produce work items without links.
557
+
558
+ Git items carry no time-tracking data — use the Jira, Linear or ClickUp sources for tracked time.
559
+
560
+ **When git fails.** A git command that exits non-zero fails the source: it lands in
561
+ `metadata[:failed_sources]` and warns on stderr, exactly like a Jira or ClickUp outage. This
562
+ matters because the alternative — an empty list — is indistinguishable from a period in which
563
+ nobody committed, and a report cannot tell you which one it is looking at. A command that hangs
564
+ is killed after `sources.git.timeout` seconds (default 30) and fails the same way, so one stuck
565
+ process cannot wedge a batch of reports.
566
+
567
+ **When there is simply nothing to report.** Zero items is a legitimate answer and never a
568
+ failure, in both the ways it happens:
569
+
570
+ - the checkout has commits, none in the requested window — `git log` exits zero with no
571
+ output, and always has;
572
+ - the checkout has **no commits at all** — a fresh `git init`, or a branch nobody has
573
+ committed to. `git log` exits 128 for this, the same as for a broken checkout, so the two
574
+ are told apart structurally rather than by reading git's (localised) wording: for an
575
+ unborn HEAD the repository itself still resolves (`git rev-parse --git-dir` succeeds) and
576
+ only `HEAD` does not. Nothing is reported as failed.
577
+
578
+ Two further failures are tolerated, because for them a non-zero exit is an ordinary answer
579
+ rather than an outage. Both warn on stderr and cost one detail:
580
+
581
+ - no `origin` remote — the items are correct, they just carry no commit links;
582
+ - `git diff` against the parent of a **root commit**, which has none — that item's
583
+ `lines_added`/`lines_deleted`/`files_changed` read as zero.
584
+
585
+ ## System dependencies
586
+
587
+ - **Git** — reads commits from a **local checkout** on the host; set `sources.git.repo_path`
588
+ to that checkout. The host machine needs the `git` binary.
589
+
590
+ ## Architecture
591
+
592
+ `Snitcher.new` returns a `Client`, the one public entry point. Everything below it is a plain
593
+ object graph — no global state, no CLI, no daemon:
594
+
595
+ ```
596
+ Snitcher.new → Snitcher::Client → ReportService → Orchestrator → Sources::*
597
+ └──────────→ Report ─┬→ #json_payload (Hash)
598
+ └→ Presenters::* (String)
599
+ └→ ReportWriter (file)
600
+ AnomalyDetector → AnomalyRules::*
601
+ ```
602
+
603
+ `Client` resolves which sources can serve the requested user and delegates to `ReportService`,
604
+ which parses the date range, runs the `Orchestrator` over the selected sources and hands the
605
+ resulting work items to `Report`. `Orchestrator` degrades rather than aborts: a source that
606
+ raises, or that was never available to start with, is recorded in `failed_sources` and the
607
+ rest of the report still renders.
608
+
609
+ ```
610
+ lib/
611
+ snitcher.rb # entry point, Zeitwerk autoloader setup
612
+ snitcher/
613
+ version.rb
614
+ client.rb # the public facade: work_report / work_reports
615
+ report_service.rb # parameters → Orchestrator + Report → Hash (saving the document)
616
+ report_writer.rb # writes markdown/html to disk, returns the path
617
+ work_item.rb # common data model
618
+ configuration.rb # YAML config loader and validator (read-only)
619
+ jql_validator.rb # JQL query sanitization
620
+ validators.rb # input validation utilities
621
+ time_formatter.rb # duration formatting
622
+ working_hours_calculator.rb # business hours time filtering
623
+ holiday_region_resolver.rb # timezone → holidays gem region mapping
624
+ sources/
625
+ source.rb # base class for all sources
626
+ jira.rb # Jira Cloud REST API v3
627
+ jira/client.rb # Faraday transport
628
+ jira/status_categories.rb # status id → category map
629
+ git.rb # local git log
630
+ git/command_runner.rb # git subprocess transport (timeout, failure reporting)
631
+ linear.rb # Linear GraphQL API
632
+ clickup.rb # ClickUp REST API
633
+ clickup/archived_lists.rb # tasks that live in archived lists
634
+ clickup/working_hours_filter.rb # working hours applied to status history
635
+ google_calendar.rb # Google Calendar meeting time
636
+ presenters/
637
+ presenter.rb # shared presenter module (escaping, truncation, total_seconds)
638
+ markdown.rb # rich .md report presenter
639
+ html.rb # self-contained HTML report presenter
640
+ templates/
641
+ report.html.erb # ERB template for HTML reports
642
+ snitcher-logo.png # embedded as base64 by the HTML presenter
643
+ orchestrator.rb # coordinates all sources
644
+ meetings_aggregator.rb # collapses calendar items into per-series meeting rows
645
+ report.rb # format dispatch (presenter registry)
646
+ anomaly.rb # anomaly value object
647
+ anomaly_context.rb # inputs shared by every rule
648
+ anomaly_detector.rb # runs the rule registry
649
+ anomaly_rules/ # one file per rule
650
+ ```
651
+
652
+ ### Adding a new source
653
+
654
+ 1. Create `lib/snitcher/sources/github.rb` inheriting from `Source`
655
+ 2. Implement `source_name`, `available?`, and `fetch`; optionally override
656
+ `unavailable_reason` to say which setting is missing
657
+ 3. Add it to `Orchestrator::SOURCE_MAP`
658
+ 4. Add its env vars to `.env.example`
659
+
660
+ No changes are needed to the orchestrator logic or the report formatter.
661
+
662
+ ## Development
663
+
664
+ Ruby 3.4.4 (pinned in `.ruby-version`).
665
+
666
+ ```bash
667
+ bundle install
668
+
669
+ # Run tests
670
+ bundle exec rake test
671
+
672
+ # Run linter
673
+ bundle exec rubocop
674
+ ```
675
+
676
+ ## Releasing
677
+
678
+ 1. Bump `Snitcher::VERSION` in `lib/snitcher/version.rb` and add a section to `CHANGELOG.md`.
679
+ 2. Merge to `master`, then tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z`.
680
+ 3. The `Release` workflow publishes the gem to RubyGems via
681
+ [Trusted Publishing](https://guides.rubygems.org/trusted-publishing/) (no API key stored).
682
+
683
+ ## Licence
684
+
685
+ MIT. See [LICENSE](LICENSE).