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
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).
|