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,564 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Progress Report</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ :root {
11
+ --color-ink: #1a1a2e;
12
+ --color-accent: #8b7fb5;
13
+ --color-border: #e5e7eb;
14
+ --color-border-subtle: #f3f4f6;
15
+ --color-secondary: #6b7280;
16
+ --color-muted: #9ca3af;
17
+ --color-body-text: #374151;
18
+ --color-white: #ffffff;
19
+ --color-link: #7c6af7;
20
+ }
21
+
22
+ body {
23
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
24
+ background: #f5f0eb;
25
+ color: var(--color-ink);
26
+ padding: 48px;
27
+ max-width: 1100px;
28
+ margin: 0 auto;
29
+ line-height: 1.5;
30
+ }
31
+
32
+ /* ── Header ── */
33
+ .report-header {
34
+ display: flex;
35
+ justify-content: space-between;
36
+ align-items: flex-start;
37
+ margin-bottom: 32px;
38
+ }
39
+ .report-label {
40
+ font-size: 0.75rem;
41
+ font-weight: 700;
42
+ letter-spacing: 0.12em;
43
+ text-transform: uppercase;
44
+ color: #c0392b;
45
+ margin-bottom: 12px;
46
+ }
47
+ .report-meta-right {
48
+ text-align: right;
49
+ color: var(--color-secondary);
50
+ font-size: 0.85rem;
51
+ }
52
+ .report-meta-right .logo {
53
+ display: block;
54
+ height: 80px;
55
+ width: auto;
56
+ margin-left: auto;
57
+ mix-blend-mode: multiply;
58
+ }
59
+ .report-title {
60
+ font-family: Georgia, 'Times New Roman', Times, serif;
61
+ font-size: 2rem;
62
+ font-weight: 700;
63
+ color: var(--color-ink);
64
+ line-height: 1.1;
65
+ margin-bottom: 4px;
66
+ word-break: break-all;
67
+ }
68
+ .report-period {
69
+ font-family: Georgia, 'Times New Roman', Times, serif;
70
+ font-size: 1.1rem;
71
+ font-weight: 600;
72
+ color: var(--color-ink);
73
+ }
74
+
75
+ hr.divider {
76
+ border: none;
77
+ border-top: 3px solid var(--color-ink);
78
+ margin-bottom: 40px;
79
+ }
80
+
81
+ /* ── Narrative overview ── */
82
+ .narrative {
83
+ font-size: 1rem;
84
+ line-height: 1.6;
85
+ color: var(--color-body-text);
86
+ margin: -24px 0 1.5rem 0;
87
+ padding: 1rem 1.25rem;
88
+ background: var(--color-border-subtle);
89
+ border-left: 3px solid var(--color-link);
90
+ border-radius: 0 6px 6px 0;
91
+ }
92
+
93
+ /* ── Summary cards ── */
94
+ .stats {
95
+ display: flex;
96
+ gap: 16px;
97
+ margin-bottom: 48px;
98
+ }
99
+ .stat-card {
100
+ flex: 1;
101
+ background: var(--color-white);
102
+ border: 1px solid var(--color-border);
103
+ border-radius: 8px;
104
+ padding: 20px 24px;
105
+ text-align: center;
106
+ }
107
+ .stat-value {
108
+ font-family: Georgia, 'Times New Roman', Times, serif;
109
+ font-size: 2.5rem;
110
+ font-weight: 700;
111
+ color: var(--color-ink);
112
+ line-height: 1;
113
+ margin-bottom: 8px;
114
+ }
115
+ .stat-label {
116
+ font-size: 0.7rem;
117
+ font-weight: 600;
118
+ letter-spacing: 0.1em;
119
+ text-transform: uppercase;
120
+ color: var(--color-accent);
121
+ }
122
+
123
+ /* ── Source sections ── */
124
+ .source-section {
125
+ margin-bottom: 48px;
126
+ }
127
+ h2 {
128
+ font-size: 1rem;
129
+ font-weight: 600;
130
+ letter-spacing: 0.05em;
131
+ text-transform: uppercase;
132
+ color: var(--color-secondary);
133
+ margin-bottom: 12px;
134
+ }
135
+
136
+ /* ── Table ── */
137
+ table {
138
+ width: 100%;
139
+ border-collapse: collapse;
140
+ }
141
+ thead th {
142
+ text-align: left;
143
+ font-size: 0.7rem;
144
+ font-weight: 700;
145
+ letter-spacing: 0.1em;
146
+ text-transform: uppercase;
147
+ color: var(--color-accent);
148
+ padding: 12px 16px;
149
+ border-bottom: 2px solid var(--color-accent);
150
+ }
151
+ tbody tr {
152
+ border-bottom: 1px solid var(--color-border);
153
+ }
154
+ tbody tr:last-child {
155
+ border-bottom: none;
156
+ }
157
+ tbody td {
158
+ padding: 16px;
159
+ font-size: 0.95rem;
160
+ vertical-align: middle;
161
+ }
162
+ .col-id {
163
+ width: 140px;
164
+ white-space: nowrap;
165
+ }
166
+ .col-time {
167
+ width: 110px;
168
+ white-space: nowrap;
169
+ }
170
+ .col-completed {
171
+ width: 120px;
172
+ white-space: nowrap;
173
+ }
174
+ td.col-completed {
175
+ font-family: 'SF Mono', 'Fira Code', 'Fira Mono', Menlo, Consolas, monospace;
176
+ font-size: 0.85rem;
177
+ color: var(--color-body-text);
178
+ }
179
+ a {
180
+ color: var(--color-link);
181
+ text-decoration: none;
182
+ }
183
+ a:hover { text-decoration: underline; }
184
+
185
+ .empty-state {
186
+ text-align: center;
187
+ padding: 60px 20px;
188
+ color: var(--color-muted);
189
+ font-style: italic;
190
+ }
191
+
192
+ footer {
193
+ margin-top: 48px;
194
+ padding-top: 16px;
195
+ border-top: 1px solid var(--color-border);
196
+ text-align: center;
197
+ font-size: 0.8rem;
198
+ color: var(--color-muted);
199
+ }
200
+
201
+ /* ── Table summary row ── */
202
+ tfoot td {
203
+ padding: 12px 16px;
204
+ font-size: 0.95rem;
205
+ font-weight: 600;
206
+ color: var(--color-secondary);
207
+ border-top: 2px solid var(--color-border);
208
+ }
209
+
210
+ /* ── Print buttons ── */
211
+ .btn-print {
212
+ display: inline-flex;
213
+ align-items: center;
214
+ gap: 6px;
215
+ padding: 10px 20px;
216
+ background: var(--color-ink);
217
+ color: var(--color-white);
218
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
219
+ font-size: 0.85rem;
220
+ font-weight: 600;
221
+ border: none;
222
+ border-radius: 6px;
223
+ cursor: pointer;
224
+ white-space: nowrap;
225
+ align-self: center;
226
+ }
227
+ .btn-print:hover { opacity: 0.85; }
228
+
229
+ .btn-print-footer {
230
+ background: none;
231
+ border: none;
232
+ color: var(--color-muted);
233
+ font-size: 0.8rem;
234
+ cursor: pointer;
235
+ padding: 0 0 0 8px;
236
+ text-decoration: underline;
237
+ text-underline-offset: 2px;
238
+ }
239
+ .btn-print-footer:hover { color: var(--color-secondary); }
240
+
241
+ /* ── Print ── */
242
+ @page { margin: 1.5cm; }
243
+
244
+ @media print {
245
+ .no-print { display: none !important; }
246
+
247
+ body {
248
+ background: var(--color-white);
249
+ padding: 0;
250
+ max-width: none;
251
+ }
252
+
253
+ .stats { break-inside: avoid; break-after: avoid; }
254
+ tbody tr { break-inside: avoid; }
255
+ tfoot { display: table-row-group; }
256
+
257
+ * { print-color-adjust: exact; -webkit-print-color-adjust: exact; }
258
+ }
259
+
260
+ /* ── Mobile (card layout) ── */
261
+ @media (max-width: 600px) {
262
+ body {
263
+ padding: 16px;
264
+ }
265
+
266
+ .stats {
267
+ gap: 10px;
268
+ margin-bottom: 32px;
269
+ }
270
+ .stat-card {
271
+ padding: 14px 16px;
272
+ }
273
+ .stat-value {
274
+ font-size: 2rem;
275
+ }
276
+
277
+ thead {
278
+ display: none;
279
+ }
280
+
281
+ table, tbody, tr, td {
282
+ display: block;
283
+ width: 100%;
284
+ }
285
+
286
+ tbody tr {
287
+ background: var(--color-white);
288
+ border: 1px solid var(--color-border);
289
+ border-radius: 8px;
290
+ margin-bottom: 12px;
291
+ padding: 12px 16px;
292
+ }
293
+ tbody tr:last-child {
294
+ border-bottom: 1px solid var(--color-border);
295
+ }
296
+
297
+ tbody td {
298
+ display: flex;
299
+ justify-content: space-between;
300
+ align-items: flex-start;
301
+ gap: 12px;
302
+ padding: 6px 0;
303
+ font-size: 0.9rem;
304
+ border: none;
305
+ }
306
+ tbody td:not(:last-child) {
307
+ border-bottom: 1px solid var(--color-border-subtle);
308
+ }
309
+
310
+ tbody td::before {
311
+ content: attr(data-label);
312
+ flex-shrink: 0;
313
+ font-size: 0.65rem;
314
+ font-weight: 700;
315
+ letter-spacing: 0.1em;
316
+ text-transform: uppercase;
317
+ color: var(--color-accent);
318
+ padding-top: 3px;
319
+ min-width: 90px;
320
+ }
321
+
322
+ tbody td[data-label="Title"] {
323
+ flex-direction: column;
324
+ gap: 4px;
325
+ }
326
+ tbody td[data-label="Title"]::before {
327
+ min-width: unset;
328
+ }
329
+
330
+ .col-id, .col-time, .col-completed {
331
+ width: auto;
332
+ white-space: normal;
333
+ }
334
+
335
+ tfoot, tfoot tr {
336
+ display: flex;
337
+ width: 100%;
338
+ }
339
+ tfoot tr {
340
+ justify-content: space-between;
341
+ align-items: center;
342
+ background: none;
343
+ border: none;
344
+ border-top: 2px solid #e5e7eb;
345
+ padding: 6px 16px;
346
+ margin-bottom: 0;
347
+ }
348
+ tfoot td {
349
+ display: block;
350
+ border: none;
351
+ padding: 6px 0;
352
+ }
353
+ tfoot td:empty {
354
+ display: none;
355
+ }
356
+
357
+ .report-header {
358
+ flex-direction: column-reverse;
359
+ align-items: center;
360
+ gap: 12px;
361
+ margin-bottom: 16px;
362
+ }
363
+ hr.divider {
364
+ margin-bottom: 24px;
365
+ }
366
+ .report-header-left {
367
+ width: 100%;
368
+ text-align: center;
369
+ }
370
+ .report-meta-right {
371
+ text-align: center;
372
+ }
373
+ .report-meta-right .logo {
374
+ height: 48px;
375
+ margin-left: auto;
376
+ margin-right: auto;
377
+ margin-bottom: 8px;
378
+ }
379
+ .report-title {
380
+ font-size: 1.05rem;
381
+ word-break: break-word;
382
+ }
383
+ .report-period {
384
+ font-size: 0.95rem;
385
+ }
386
+ }
387
+ </style>
388
+ </head>
389
+ <body>
390
+
391
+ <div class="report-header">
392
+ <div class="report-header-left">
393
+ <p class="report-label">Progress Report</p>
394
+ <% if config[:user_email] %>
395
+ <p class="report-title"><%= ERB::Util.html_escape(config[:user_email]) %></p>
396
+ <% end %>
397
+ <% if config[:since] && config[:until_time] %>
398
+ <%
399
+ since_str = config[:since].strftime('%B %-d, %Y')
400
+ until_str = (config[:until_time] - Snitcher::TimeFormatter::SECONDS_PER_DAY).strftime('%B %-d, %Y')
401
+ %>
402
+ <p class="report-period"><%= ERB::Util.html_escape(since_str) %> &ndash; <%= ERB::Util.html_escape(until_str) %></p>
403
+ <% end %>
404
+ </div>
405
+ <button class="btn-print no-print" onclick="window.print()">
406
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
407
+ <path d="M6 9V2h12v7"/><rect x="6" y="17" width="12" height="5"/><path d="M6 13H4a2 2 0 0 0-2 2v4h4v-4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4h4v-4a2 2 0 0 0-2-2h-2"/>
408
+ </svg>
409
+ Print to PDF
410
+ </button>
411
+
412
+ <div class="report-meta-right">
413
+ <img class="logo" src="<%= logo_data_uri %>" alt="Snitcher">
414
+ </div>
415
+ </div>
416
+
417
+ <hr class="divider">
418
+
419
+ <% if items.empty? %>
420
+ <div class="empty-state"><p>No work items found.</p></div>
421
+ <% else %>
422
+ <div class="stats">
423
+ <div class="stat-card">
424
+ <p class="stat-value"><%= stats[:hours_logged] %></p>
425
+ <p class="stat-label">Hours Logged</p>
426
+ </div>
427
+ <div class="stat-card">
428
+ <p class="stat-value"><%= stats[:tracker_items] %></p>
429
+ <p class="stat-label">Tickets Closed</p>
430
+ </div>
431
+ <div class="stat-card">
432
+ <p class="stat-value"><%= stats[:commits_count] %></p>
433
+ <p class="stat-label">Commits</p>
434
+ </div>
435
+ <% if stats[:meeting_aggregate][:event_count].positive? %>
436
+ <div class="stat-card">
437
+ <p class="stat-value"><%= stats[:meeting_hours] %></p>
438
+ <p class="stat-label">Meeting Hours</p>
439
+ </div>
440
+ <% end %>
441
+ <% if anomalies.any? %>
442
+ <div class="stat-card">
443
+ <p class="stat-value"><%= anomalies.count %></p>
444
+ <p class="stat-label">Potential Problems</p>
445
+ </div>
446
+ <% end %>
447
+ </div>
448
+
449
+ <% if (narrative = config[:narrative]) %>
450
+ <div class="narrative">
451
+ <p><%= render_narrative_html(narrative) %></p>
452
+ </div>
453
+ <% end %>
454
+
455
+ <% grouped.each do |source, source_items| %>
456
+ <%
457
+ src_seconds = source_items.sum { |i| i.time_spent_seconds || 0 }
458
+ src_time = src_seconds > 0 ? Snitcher::TimeFormatter.format_duration(src_seconds) : '-'
459
+ src_count = source_items.count
460
+ %>
461
+ <div class="source-section">
462
+ <h2><%= ERB::Util.html_escape(source.to_s) %></h2>
463
+ <table>
464
+ <thead>
465
+ <tr>
466
+ <th class="col-id">ID</th>
467
+ <th>Title</th>
468
+ <th class="col-time">Time Spent</th>
469
+ <th class="col-completed">Completed</th>
470
+ </tr>
471
+ </thead>
472
+ <tbody>
473
+ <% source_items.each do |item| %>
474
+ <tr>
475
+ <td class="col-id" data-label="ID">
476
+ <% if (url = safe_url(item.url)) %>
477
+ <a href="<%= url %>" target="_blank" rel="noopener noreferrer">
478
+ <%= ERB::Util.html_escape(item.external_id) %>
479
+ </a>
480
+ <% else %>
481
+ <%= ERB::Util.html_escape(item.external_id) %>
482
+ <% end %>
483
+ </td>
484
+ <td data-label="Title"><%= ERB::Util.html_escape(item.title) %></td>
485
+ <td class="col-time" data-label="Time Spent"><%= ERB::Util.html_escape(item.time_spent_human || '-') %></td>
486
+ <td class="col-completed" data-label="Completed"><%= ERB::Util.html_escape(item.completed_date || '-') %></td>
487
+ </tr>
488
+ <% end %>
489
+ </tbody>
490
+ <tfoot>
491
+ <tr>
492
+ <td colspan="2"><%= src_count %> <%= item_label(source, src_count) %></td>
493
+ <td class="col-time"><%= ERB::Util.html_escape(src_time) %></td>
494
+ <td class="col-completed"></td>
495
+ </tr>
496
+ </tfoot>
497
+ </table>
498
+ </div>
499
+ <% end %>
500
+
501
+ <% if stats[:meeting_aggregate][:event_count].positive? %>
502
+ <% agg = stats[:meeting_aggregate] %>
503
+ <div class="source-section">
504
+ <h2>Meetings</h2>
505
+ <table>
506
+ <thead>
507
+ <tr>
508
+ <th>Meeting</th>
509
+ <th class="col-time">Count</th>
510
+ <th class="col-time">Time</th>
511
+ </tr>
512
+ </thead>
513
+ <tbody>
514
+ <% agg[:rows].each do |row| %>
515
+ <tr>
516
+ <td data-label="Meeting"><%= ERB::Util.html_escape(row[:title]) %></td>
517
+ <td class="col-time" data-label="Count"><%= row[:count] %></td>
518
+ <td class="col-time" data-label="Time"><%= ERB::Util.html_escape(Snitcher::TimeFormatter.format_duration(row[:total_seconds])) %></td>
519
+ </tr>
520
+ <% end %>
521
+ </tbody>
522
+ <tfoot>
523
+ <tr>
524
+ <td><%= agg[:event_count] %> <%= agg[:event_count] == 1 ? 'event' : 'events' %></td>
525
+ <td class="col-time"></td>
526
+ <td class="col-time"><%= ERB::Util.html_escape(Snitcher::TimeFormatter.format_duration(agg[:total_seconds])) %></td>
527
+ </tr>
528
+ </tfoot>
529
+ </table>
530
+ </div>
531
+ <% end %>
532
+
533
+ <% end %>
534
+
535
+ <%# Outside the items.empty? branch on purpose: a period with no items is exactly when
536
+ the detected anomalies are the only thing worth reading. %>
537
+ <% if anomalies.any? %>
538
+ <div class="source-section">
539
+ <h2>Potential Problems</h2>
540
+ <table>
541
+ <thead>
542
+ <tr><th class="col-id">Severity</th><th>Signal</th><th>Details</th></tr>
543
+ </thead>
544
+ <tbody>
545
+ <% anomalies.each do |a| %>
546
+ <tr>
547
+ <td class="col-id" data-label="Severity">
548
+ <span style="color: <%= severity_color(a.severity) %>; font-weight: 600;"><%= ERB::Util.html_escape(a.severity.to_s) %></span>
549
+ </td>
550
+ <td data-label="Signal"><%= ERB::Util.html_escape(a.title) %></td>
551
+ <td data-label="Details"><%= ERB::Util.html_escape(a.description) %></td>
552
+ </tr>
553
+ <% end %>
554
+ </tbody>
555
+ </table>
556
+ </div>
557
+ <% end %>
558
+
559
+ <footer>
560
+ Generated on <%= render_time.strftime('%Y-%m-%d') %> at <%= render_time.strftime('%H:%M:%S') %>
561
+ <button class="btn-print-footer no-print" onclick="window.print()">Print to PDF</button>
562
+ </footer>
563
+ </body>
564
+ </html>
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Snitcher
4
+ # Shared time formatting utilities.
5
+ # Used by WorkItem and Report to ensure consistent duration display.
6
+ module TimeFormatter
7
+ SECONDS_PER_HOUR = 3_600
8
+ SECONDS_PER_DAY = 86_400 # 24 * 60 * 60
9
+
10
+ # Formats seconds into human-readable duration (e.g., "2h 30m")
11
+ # @param seconds [Integer] duration in seconds
12
+ # @return [String] formatted duration
13
+ def format_duration(seconds)
14
+ return '0m' if seconds.nil? || seconds <= 0
15
+
16
+ hours = seconds / SECONDS_PER_HOUR
17
+ minutes = (seconds % SECONDS_PER_HOUR) / 60
18
+
19
+ parts = []
20
+ parts << "#{hours}h" if hours.positive?
21
+ parts << "#{minutes}m" if minutes.positive?
22
+ parts.empty? ? '0m' : parts.join(' ')
23
+ end
24
+
25
+ module_function :format_duration
26
+ end
27
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tzinfo'
4
+
5
+ module Snitcher
6
+ # Validation utilities for user input sanitization.
7
+ # Prevents injection attacks when user input is used in queries or URLs.
8
+ module Validators
9
+ # RFC 5322 simplified email pattern - validates basic email format
10
+ # Prevents JQL injection by ensuring email doesn't contain query operators
11
+ EMAIL_LOCAL_PART = "[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+"
12
+ EMAIL_DOMAIN_PART = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?'
13
+ EMAIL_REGEX = /\A#{EMAIL_LOCAL_PART}@#{EMAIL_DOMAIN_PART}(?:\.#{EMAIL_DOMAIN_PART})*\z/
14
+
15
+ # Alphanumeric pattern for IDs used in URL paths
16
+ # Prevents path injection by ensuring IDs contain only safe characters
17
+ ALPHANUMERIC_ID_REGEX = /\A[a-zA-Z0-9_-]+\z/
18
+
19
+ class ValidationError < Error; end
20
+
21
+ # Validates email format to prevent injection in JQL queries
22
+ # @param email [String] email address to validate
23
+ # @raise [ValidationError] if email format is invalid
24
+ # @return [String] the validated email
25
+ def self.validate_email!(email)
26
+ raise ValidationError, 'Email is required' if email.nil? || email.empty?
27
+ raise ValidationError, "Invalid email format: #{email}" unless email.match?(EMAIL_REGEX)
28
+
29
+ email
30
+ end
31
+
32
+ # Validates ID format for safe use in URL paths
33
+ # @param id [String] ID to validate (team_id, user_id, etc.)
34
+ # @param name [String] descriptive name for error messages
35
+ # @raise [ValidationError] if ID format is invalid
36
+ # @return [String] the validated ID
37
+ def self.validate_id!(id, name: 'ID')
38
+ raise ValidationError, "#{name} is required" if id.nil? || id.to_s.empty?
39
+ raise ValidationError, "Invalid #{name} format: #{id}" unless id.to_s.match?(ALPHANUMERIC_ID_REGEX)
40
+
41
+ id.to_s
42
+ end
43
+
44
+ # Validates IANA timezone identifier using TZInfo.
45
+ # @param timezone [String] IANA timezone name (e.g., 'Europe/Warsaw')
46
+ # @raise [ValidationError] if timezone is invalid or not found
47
+ # @return [String] the validated timezone name
48
+ def self.validate_timezone!(timezone)
49
+ raise ValidationError, 'Timezone is required' if timezone.nil? || timezone.to_s.strip.empty?
50
+
51
+ stripped = timezone.to_s.strip
52
+ TZInfo::Timezone.get(stripped)
53
+ stripped
54
+ rescue TZInfo::InvalidTimezoneIdentifier
55
+ raise ValidationError,
56
+ "Unknown timezone: '#{stripped}'. Use IANA format, e.g. 'Europe/Warsaw', 'America/New_York'"
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Snitcher
4
+ VERSION = '0.2.0'
5
+ end