github-pulse 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.
@@ -0,0 +1,747 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Github
4
+ module Pulse
5
+ class HtmlReporter
6
+ attr_reader :report
7
+
8
+ def initialize(report)
9
+ @report = report
10
+ end
11
+
12
+ def generate
13
+ <<~HTML
14
+ <!DOCTYPE html>
15
+ <html lang="en">
16
+ <head>
17
+ <meta charset="UTF-8">
18
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
19
+ <title>GitHub Pulse Report - #{report.dig(:metadata, :repository, :full_name) || 'Repository'}</title>
20
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
21
+ <script src="https://cdn.jsdelivr.net/npm/date-fns@2.29.3/index.min.js"></script>
22
+ <style>
23
+ * {
24
+ margin: 0;
25
+ padding: 0;
26
+ box-sizing: border-box;
27
+ }
28
+ body {
29
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
30
+ line-height: 1.6;
31
+ color: #333;
32
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
33
+ min-height: 100vh;
34
+ padding: 20px;
35
+ }
36
+ .container {
37
+ max-width: 1400px;
38
+ margin: 0 auto;
39
+ background: rgba(255, 255, 255, 0.95);
40
+ border-radius: 20px;
41
+ padding: 30px;
42
+ box-shadow: 0 20px 60px rgba(0,0,0,0.3);
43
+ }
44
+ h1 {
45
+ color: #2d3748;
46
+ margin-bottom: 10px;
47
+ font-size: 2.5em;
48
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
49
+ -webkit-background-clip: text;
50
+ -webkit-text-fill-color: transparent;
51
+ }
52
+ .subtitle {
53
+ color: #718096;
54
+ margin-bottom: 30px;
55
+ font-size: 1.1em;
56
+ }
57
+ .stats-grid {
58
+ display: grid;
59
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
60
+ gap: 20px;
61
+ margin-bottom: 40px;
62
+ }
63
+ .stat-card {
64
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
65
+ color: white;
66
+ padding: 20px;
67
+ border-radius: 15px;
68
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
69
+ }
70
+ .stat-value {
71
+ font-size: 2em;
72
+ font-weight: bold;
73
+ margin-bottom: 5px;
74
+ }
75
+ .stat-label {
76
+ opacity: 0.9;
77
+ font-size: 0.9em;
78
+ text-transform: uppercase;
79
+ letter-spacing: 1px;
80
+ }
81
+ .charts-grid {
82
+ display: grid;
83
+ grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
84
+ gap: 30px;
85
+ margin-bottom: 30px;
86
+ }
87
+ .chart-container {
88
+ background: white;
89
+ padding: 25px;
90
+ border-radius: 15px;
91
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
92
+ }
93
+ .chart-title {
94
+ font-size: 1.3em;
95
+ color: #2d3748;
96
+ margin-bottom: 20px;
97
+ font-weight: 600;
98
+ }
99
+ canvas {
100
+ max-height: 400px;
101
+ }
102
+ .contributors-table {
103
+ background: white;
104
+ border-radius: 15px;
105
+ padding: 25px;
106
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
107
+ margin-top: 30px;
108
+ }
109
+ table {
110
+ width: 100%;
111
+ border-collapse: collapse;
112
+ }
113
+ th {
114
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
115
+ color: white;
116
+ padding: 12px;
117
+ text-align: left;
118
+ font-weight: 600;
119
+ }
120
+ td {
121
+ padding: 12px;
122
+ border-bottom: 1px solid #e2e8f0;
123
+ }
124
+ tr:hover {
125
+ background-color: #f7fafc;
126
+ }
127
+ .progress-bar {
128
+ width: 100%;
129
+ height: 20px;
130
+ background: #e2e8f0;
131
+ border-radius: 10px;
132
+ overflow: hidden;
133
+ }
134
+ .progress-fill {
135
+ height: 100%;
136
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
137
+ border-radius: 10px;
138
+ transition: width 0.3s ease;
139
+ }
140
+ .metadata {
141
+ background: #f7fafc;
142
+ padding: 15px;
143
+ border-radius: 10px;
144
+ margin-bottom: 30px;
145
+ display: flex;
146
+ justify-content: space-between;
147
+ flex-wrap: wrap;
148
+ gap: 20px;
149
+ }
150
+ .metadata-item {
151
+ display: flex;
152
+ align-items: center;
153
+ gap: 10px;
154
+ }
155
+ .metadata-label {
156
+ font-weight: 600;
157
+ color: #4a5568;
158
+ }
159
+ .no-data {
160
+ text-align: center;
161
+ padding: 40px;
162
+ color: #718096;
163
+ font-style: italic;
164
+ }
165
+ </style>
166
+ </head>
167
+ <body>
168
+ <div class="container">
169
+ <h1>GitHub Pulse Report</h1>
170
+ <div class="subtitle">Repository Activity Analysis</div>
171
+
172
+ #{generate_metadata_section}
173
+ #{generate_stats_cards}
174
+
175
+ <div class="charts-grid">
176
+ #{generate_commit_activity_chart}
177
+ #{generate_lines_of_code_chart}
178
+ #{generate_commits_timeline_chart}
179
+ #{generate_pull_requests_chart}
180
+ #{generate_prs_timeline_chart}
181
+ #{generate_pr_status_chart}
182
+ #{generate_pr_cycle_time_chart}
183
+ #{generate_pr_size_mix_chart}
184
+ #{generate_open_prs_aging_chart}
185
+ </div>
186
+ #{generate_commit_heatmap}
187
+
188
+ #{generate_contributors_table}
189
+ </div>
190
+
191
+ <script>
192
+ #{generate_chart_scripts}
193
+ </script>
194
+ </body>
195
+ </html>
196
+ HTML
197
+ end
198
+
199
+ private
200
+
201
+ def generate_metadata_section
202
+ metadata = report[:metadata]
203
+ repo = metadata[:repository]
204
+ period = metadata[:period]
205
+
206
+ items = []
207
+ items << %(<div class="metadata-item"><span class="metadata-label">Repository:</span> #{repo[:full_name]}</div>) if repo
208
+ items << %(<div class="metadata-item"><span class="metadata-label">Analyzed:</span> #{format_time(metadata[:analyzed_at])}</div>)
209
+ items << %(<div class="metadata-item"><span class="metadata-label">Period:</span> #{period[:since] || 'All time'} to #{period[:until] || 'Present'}</div>) if period[:since] || period[:until]
210
+
211
+ %(<div class="metadata">#{items.join}</div>)
212
+ end
213
+
214
+ def generate_stats_cards
215
+ stats = calculate_stats
216
+
217
+ <<~HTML
218
+ <div class="stats-grid">
219
+ <div class="stat-card">
220
+ <div class="stat-value">#{stats[:total_contributors]}</div>
221
+ <div class="stat-label">Contributors</div>
222
+ </div>
223
+ <div class="stat-card">
224
+ <div class="stat-value">#{stats[:total_commits]}</div>
225
+ <div class="stat-label">Total Commits</div>
226
+ </div>
227
+ <div class="stat-card">
228
+ <div class="stat-value">#{stats[:total_prs]}</div>
229
+ <div class="stat-label">Pull Requests</div>
230
+ </div>
231
+ <div class="stat-card">
232
+ <div class="stat-value">#{format_number(stats[:total_lines])}</div>
233
+ <div class="stat-label">Lines of Code</div>
234
+ </div>
235
+ <div class="stat-card">
236
+ <div class="stat-value">+#{format_number(stats[:total_additions])}</div>
237
+ <div class="stat-label">Additions</div>
238
+ </div>
239
+ <div class="stat-card">
240
+ <div class="stat-value">-#{format_number(stats[:total_deletions])}</div>
241
+ <div class="stat-label">Deletions</div>
242
+ </div>
243
+ </div>
244
+ HTML
245
+ end
246
+
247
+ def generate_commit_activity_chart
248
+ return %(<div class="chart-container"><div class="no-data">No commit activity data available</div></div>) if report[:visualization_data].nil? || report[:visualization_data][:commit_activity_chart].nil?
249
+
250
+ <<~HTML
251
+ <div class="chart-container">
252
+ <h3 class="chart-title">Daily Commit Activity</h3>
253
+ <canvas id="commitActivityChart"></canvas>
254
+ </div>
255
+ HTML
256
+ end
257
+
258
+ def generate_lines_of_code_chart
259
+ return %(<div class="chart-container"><div class="no-data">No lines of code data available</div></div>) if report[:visualization_data].nil? || report[:visualization_data][:lines_of_code_chart].nil?
260
+
261
+ <<~HTML
262
+ <div class="chart-container">
263
+ <h3 class="chart-title">Lines of Code by Contributor</h3>
264
+ <canvas id="linesOfCodeChart"></canvas>
265
+ </div>
266
+ HTML
267
+ end
268
+
269
+ def generate_commits_timeline_chart
270
+ return %(<div class="chart-container"><div class="no-data">No commits timeline data available</div></div>) if report[:visualization_data].nil? || report[:visualization_data][:commits_timeline].nil?
271
+
272
+ <<~HTML
273
+ <div class="chart-container">
274
+ <h3 class="chart-title">Commits Over Time</h3>
275
+ <canvas id="commitsTimelineChart"></canvas>
276
+ </div>
277
+ HTML
278
+ end
279
+
280
+ def generate_pull_requests_chart
281
+ return "" if report[:pull_requests].empty?
282
+ "" # We'll use separate charts for PRs
283
+ end
284
+
285
+ def generate_prs_timeline_chart
286
+ return %(<div class="chart-container"><div class="no-data">No pull requests data available</div></div>) if report[:visualization_data].nil? || report[:visualization_data][:pull_requests_timeline].nil?
287
+
288
+ <<~HTML
289
+ <div class="chart-container">
290
+ <h3 class="chart-title">Pull Requests Over Time</h3>
291
+ <canvas id="prsTimelineChart"></canvas>
292
+ </div>
293
+ HTML
294
+ end
295
+
296
+ def generate_pr_status_chart
297
+ return %(<div class="chart-container"><div class="no-data">No pull requests data available</div></div>) if report[:pull_requests].empty?
298
+
299
+ <<~HTML
300
+ <div class="chart-container">
301
+ <h3 class="chart-title">Pull Requests by Status</h3>
302
+ <canvas id="prStatusChart"></canvas>
303
+ </div>
304
+ HTML
305
+ end
306
+
307
+ def generate_pr_cycle_time_chart
308
+ return %(<div class="chart-container"><div class="no-data">No PR cycle time data available</div></div>) if report[:visualization_data].nil? || report[:visualization_data][:pr_cycle_time_timeline].nil?
309
+ <<~HTML
310
+ <div class="chart-container">
311
+ <h3 class="chart-title">PR Cycle Time (days)</h3>
312
+ <canvas id="prCycleTimeChart"></canvas>
313
+ </div>
314
+ HTML
315
+ end
316
+
317
+ def generate_pr_size_mix_chart
318
+ return %(<div class="chart-container"><div class="no-data">No PR size data available</div></div>) if report[:visualization_data].nil? || report[:visualization_data][:pr_size_mix_timeline].nil?
319
+ <<~HTML
320
+ <div class="chart-container">
321
+ <h3 class="chart-title">PR Size Mix Over Time</h3>
322
+ <canvas id="prSizeMixChart"></canvas>
323
+ </div>
324
+ HTML
325
+ end
326
+
327
+ def generate_open_prs_aging_chart
328
+ return %(<div class="chart-container"><div class="no-data">No open PRs</div></div>) if report[:visualization_data].nil? || report[:visualization_data][:open_prs_aging].nil?
329
+ <<~HTML
330
+ <div class="chart-container">
331
+ <h3 class="chart-title">Open PRs Aging</h3>
332
+ <canvas id="openPrsAgingChart"></canvas>
333
+ </div>
334
+ HTML
335
+ end
336
+
337
+ def generate_commit_heatmap
338
+ return %(<div class="chart-container"><div class="no-data">No commit heatmap data available</div></div>) if report[:visualization_data].nil? || report[:visualization_data][:commit_activity_heatmap].nil?
339
+ heatmap = report[:visualization_data][:commit_activity_heatmap]
340
+ days = %w[Sun Mon Tue Wed Thu Fri Sat]
341
+ # Build a simple grid table with intensity via inline background
342
+ rows = heatmap.each_with_index.map do |hours, wday|
343
+ cells = hours.each_with_index.map do |count, hour|
344
+ intensity = [count, 10].min # cap for color scale
345
+ color = "rgba(102,126,234,#{0.1 + intensity * 0.09})"
346
+ %(<td title="#{days[wday]} #{hour}:00 — #{count}" style="background: #{color}; text-align:center; font-size: 12px;">#{count}</td>)
347
+ end.join
348
+ %(<tr><th style="position:sticky;left:0;background:#fff;">#{days[wday]}</th>#{cells}</tr>)
349
+ end.join
350
+ hours_header = (0..23).map { |h| %(<th>#{h}</th>) }.join
351
+ <<~HTML
352
+ <div class="chart-container">
353
+ <h3 class="chart-title">Commit Activity Heatmap</h3>
354
+ <div style="overflow:auto">
355
+ <table>
356
+ <thead>
357
+ <tr><th></th>#{hours_header}</tr>
358
+ </thead>
359
+ <tbody>
360
+ #{rows}
361
+ </tbody>
362
+ </table>
363
+ </div>
364
+ </div>
365
+ HTML
366
+ end
367
+
368
+ def generate_contributors_table
369
+ contributors = gather_contributor_stats
370
+ return "" if contributors.empty?
371
+
372
+ max_commits = contributors.map { |c| c[:commits] }.max.to_f
373
+
374
+ rows = contributors.map do |contributor|
375
+ <<~ROW
376
+ <tr>
377
+ <td>#{contributor[:name]}</td>
378
+ <td>#{contributor[:commits]}</td>
379
+ <td>
380
+ <div class="progress-bar">
381
+ <div class="progress-fill" style="width: #{(contributor[:commits] / max_commits * 100).round}%"></div>
382
+ </div>
383
+ </td>
384
+ <td>+#{format_number(contributor[:additions])}</td>
385
+ <td>-#{format_number(contributor[:deletions])}</td>
386
+ <td>#{format_number(contributor[:lines])} lines</td>
387
+ <td>#{contributor[:prs]} PRs</td>
388
+ </tr>
389
+ ROW
390
+ end.join
391
+
392
+ <<~HTML
393
+ <div class="contributors-table">
394
+ <h3 class="chart-title">Contributor Statistics</h3>
395
+ <table>
396
+ <thead>
397
+ <tr>
398
+ <th>Contributor</th>
399
+ <th>Commits</th>
400
+ <th>Activity</th>
401
+ <th>Additions</th>
402
+ <th>Deletions</th>
403
+ <th>Current Lines</th>
404
+ <th>Pull Requests</th>
405
+ </tr>
406
+ </thead>
407
+ <tbody>
408
+ #{rows}
409
+ </tbody>
410
+ </table>
411
+ </div>
412
+ HTML
413
+ end
414
+
415
+ def generate_chart_scripts
416
+ viz_data = report[:visualization_data] || {}
417
+
418
+ scripts = []
419
+
420
+ # Commit Activity Chart
421
+ if viz_data[:commit_activity_chart]
422
+ data = viz_data[:commit_activity_chart]
423
+ labels = data.map { |d| "'#{d[:date]}'" }.join(', ')
424
+ values = data.map { |d| d[:commits] }.join(', ')
425
+
426
+ scripts << <<~JS
427
+ new Chart(document.getElementById('commitActivityChart'), {
428
+ type: 'line',
429
+ data: {
430
+ labels: [#{labels}],
431
+ datasets: [{
432
+ label: 'Commits',
433
+ data: [#{values}],
434
+ borderColor: '#667eea',
435
+ backgroundColor: 'rgba(102, 126, 234, 0.1)',
436
+ tension: 0.4,
437
+ fill: true
438
+ }]
439
+ },
440
+ options: {
441
+ responsive: true,
442
+ maintainAspectRatio: false,
443
+ plugins: {
444
+ legend: { display: false }
445
+ },
446
+ scales: {
447
+ y: { beginAtZero: true }
448
+ }
449
+ }
450
+ });
451
+ JS
452
+ end
453
+
454
+ # Lines of Code Chart
455
+ if viz_data[:lines_of_code_chart]
456
+ data = viz_data[:lines_of_code_chart]
457
+ labels = data.map { |d| "'#{d[:author]}'" }.join(', ')
458
+ values = data.map { |d| d[:lines] }.join(', ')
459
+
460
+ scripts << <<~JS
461
+ new Chart(document.getElementById('linesOfCodeChart'), {
462
+ type: 'bar',
463
+ data: {
464
+ labels: [#{labels}],
465
+ datasets: [{
466
+ label: 'Lines of Code',
467
+ data: [#{values}],
468
+ backgroundColor: [
469
+ '#667eea', '#764ba2', '#f093fb', '#f5576c',
470
+ '#4facfe', '#00f2fe', '#43e97b', '#fa709a'
471
+ ]
472
+ }]
473
+ },
474
+ options: {
475
+ responsive: true,
476
+ maintainAspectRatio: false,
477
+ plugins: {
478
+ legend: { display: false }
479
+ },
480
+ scales: {
481
+ y: { beginAtZero: true }
482
+ }
483
+ }
484
+ });
485
+ JS
486
+ end
487
+
488
+ # Commits Timeline Chart
489
+ if viz_data[:commits_timeline]
490
+ data = viz_data[:commits_timeline]
491
+ labels = data.map { |d| "'#{d[:date]}'" }
492
+ authors = data.flat_map { |d| d[:authors].keys }.uniq
493
+
494
+ datasets = authors.map.with_index do |author, i|
495
+ values = data.map { |d| d[:authors][author] || 0 }
496
+ colors = ['#667eea', '#764ba2', '#f093fb', '#f5576c', '#4facfe', '#00f2fe']
497
+ {
498
+ label: author,
499
+ data: values,
500
+ backgroundColor: colors[i % colors.length],
501
+ borderColor: colors[i % colors.length]
502
+ }
503
+ end
504
+
505
+ scripts << <<~JS
506
+ new Chart(document.getElementById('commitsTimelineChart'), {
507
+ type: 'bar',
508
+ data: {
509
+ labels: [#{labels.join(', ')}],
510
+ datasets: #{datasets.to_json}
511
+ },
512
+ options: {
513
+ responsive: true,
514
+ maintainAspectRatio: false,
515
+ scales: {
516
+ x: { stacked: true },
517
+ y: { stacked: true, beginAtZero: true }
518
+ }
519
+ }
520
+ });
521
+ JS
522
+ end
523
+
524
+ # Pull Requests Timeline Chart
525
+ if viz_data[:pull_requests_timeline]
526
+ data = viz_data[:pull_requests_timeline]
527
+ labels = data.map { |d| "'#{d[:date]}'" }
528
+ authors = data.flat_map { |d| d[:authors].keys }.uniq
529
+
530
+ datasets = authors.map.with_index do |author, i|
531
+ values = data.map { |d| d[:authors][author] || 0 }
532
+ colors = ['#667eea', '#764ba2', '#f093fb', '#f5576c', '#4facfe', '#00f2fe']
533
+ {
534
+ label: author,
535
+ data: values,
536
+ backgroundColor: colors[i % colors.length],
537
+ borderColor: colors[i % colors.length]
538
+ }
539
+ end
540
+
541
+ scripts << <<~JS
542
+ new Chart(document.getElementById('prsTimelineChart'), {
543
+ type: 'line',
544
+ data: {
545
+ labels: [#{labels.join(', ')}],
546
+ datasets: #{datasets.to_json}
547
+ },
548
+ options: {
549
+ responsive: true,
550
+ maintainAspectRatio: false,
551
+ scales: {
552
+ y: {
553
+ beginAtZero: true,
554
+ ticks: {
555
+ stepSize: 1
556
+ }
557
+ }
558
+ },
559
+ plugins: {
560
+ tooltip: {
561
+ mode: 'index',
562
+ intersect: false
563
+ }
564
+ }
565
+ }
566
+ });
567
+ JS
568
+ end
569
+
570
+ # Pull Requests Status Chart
571
+ unless report[:pull_requests].empty?
572
+ pr_stats = { merged: 0, open: 0, closed: 0 }
573
+ report[:pull_requests].each do |_, data|
574
+ pr_stats[:merged] += data[:merged]
575
+ pr_stats[:open] += data[:open]
576
+ pr_stats[:closed] += data[:closed]
577
+ end
578
+
579
+ scripts << <<~JS
580
+ new Chart(document.getElementById('prStatusChart'), {
581
+ type: 'doughnut',
582
+ data: {
583
+ labels: ['Merged', 'Open', 'Closed'],
584
+ datasets: [{
585
+ data: [#{pr_stats[:merged]}, #{pr_stats[:open]}, #{pr_stats[:closed]}],
586
+ backgroundColor: ['#48bb78', '#4299e1', '#f56565']
587
+ }]
588
+ },
589
+ options: {
590
+ responsive: true,
591
+ maintainAspectRatio: false
592
+ }
593
+ });
594
+ JS
595
+ end
596
+
597
+ # PR Cycle Time Chart
598
+ if viz_data[:pr_cycle_time_timeline]
599
+ data = viz_data[:pr_cycle_time_timeline]
600
+ labels = data.map { |d| "'#{d[:week]}'" }.join(', ')
601
+ p50 = data.map { |d| d[:p50] }.join(', ')
602
+ p90 = data.map { |d| d[:p90] }.join(', ')
603
+ maxv = data.map { |d| d[:max] }.join(', ')
604
+ scripts << <<~JS
605
+ new Chart(document.getElementById('prCycleTimeChart'), {
606
+ type: 'line',
607
+ data: {
608
+ labels: [#{labels}],
609
+ datasets: [
610
+ { label: 'p50', data: [#{p50}], borderColor: '#48bb78', fill: false },
611
+ { label: 'p90', data: [#{p90}], borderColor: '#ed8936', fill: false },
612
+ { label: 'max', data: [#{maxv}], borderColor: '#f56565', fill: false }
613
+ ]
614
+ },
615
+ options: { responsive: true, maintainAspectRatio: false }
616
+ });
617
+ JS
618
+ end
619
+
620
+ # PR Size Mix Chart
621
+ if viz_data[:pr_size_mix_timeline]
622
+ data = viz_data[:pr_size_mix_timeline]
623
+ labels = data.map { |d| "'#{d[:week]}'" }.join(', ')
624
+ small = data.map { |d| d[:small] }.join(', ')
625
+ medium = data.map { |d| d[:medium] }.join(', ')
626
+ large = data.map { |d| d[:large] }.join(', ')
627
+ scripts << <<~JS
628
+ new Chart(document.getElementById('prSizeMixChart'), {
629
+ type: 'bar',
630
+ data: {
631
+ labels: [#{labels}],
632
+ datasets: [
633
+ { label: 'Small', data: [#{small}], backgroundColor: '#63b3ed' },
634
+ { label: 'Medium', data: [#{medium}], backgroundColor: '#667eea' },
635
+ { label: 'Large', data: [#{large}], backgroundColor: '#764ba2' }
636
+ ]
637
+ },
638
+ options: { responsive: true, maintainAspectRatio: false, scales: { x: { stacked: true }, y: { stacked: true, beginAtZero: true } } }
639
+ });
640
+ JS
641
+ end
642
+
643
+ # Open PRs Aging Chart
644
+ if viz_data[:open_prs_aging]
645
+ labels = viz_data[:open_prs_aging].keys.map { |k| "'#{k}'" }.join(', ')
646
+ values = viz_data[:open_prs_aging].values.join(', ')
647
+ scripts << <<~JS
648
+ new Chart(document.getElementById('openPrsAgingChart'), {
649
+ type: 'doughnut',
650
+ data: {
651
+ labels: [#{labels}],
652
+ datasets: [{ data: [#{values}], backgroundColor: ['#68d391', '#63b3ed', '#ed8936', '#f56565'] }]
653
+ },
654
+ options: { responsive: true, maintainAspectRatio: false }
655
+ });
656
+ JS
657
+ end
658
+
659
+ scripts.join("\n")
660
+ end
661
+
662
+ def calculate_stats
663
+ stats = {
664
+ total_contributors: 0,
665
+ total_commits: 0,
666
+ total_prs: 0,
667
+ total_lines: 0,
668
+ total_additions: 0,
669
+ total_deletions: 0
670
+ }
671
+
672
+ # Count contributors
673
+ contributors = Set.new
674
+ contributors.merge(report[:commits].keys) if report[:commits]
675
+ contributors.merge(report[:pull_requests].keys) if report[:pull_requests]
676
+ stats[:total_contributors] = contributors.size
677
+
678
+ # Count commits and changes
679
+ if report[:commits]
680
+ report[:commits].each do |_, data|
681
+ stats[:total_commits] += data[:total_commits]
682
+ stats[:total_additions] += data[:total_additions]
683
+ stats[:total_deletions] += data[:total_deletions]
684
+ end
685
+ end
686
+
687
+ # Count PRs
688
+ if report[:pull_requests]
689
+ report[:pull_requests].each do |_, data|
690
+ stats[:total_prs] += data[:total_prs]
691
+ end
692
+ end
693
+
694
+ # Count lines
695
+ if report[:lines_of_code]
696
+ stats[:total_lines] = report[:lines_of_code].values.sum
697
+ end
698
+
699
+ stats
700
+ end
701
+
702
+ def gather_contributor_stats
703
+ contributors = {}
704
+
705
+ # Gather commit stats
706
+ if report[:commits]
707
+ report[:commits].each do |author, data|
708
+ contributors[author] ||= { name: author, commits: 0, additions: 0, deletions: 0, lines: 0, prs: 0 }
709
+ contributors[author][:commits] = data[:total_commits]
710
+ contributors[author][:additions] = data[:total_additions]
711
+ contributors[author][:deletions] = data[:total_deletions]
712
+ end
713
+ end
714
+
715
+ # Add lines of code
716
+ if report[:lines_of_code]
717
+ report[:lines_of_code].each do |author, lines|
718
+ contributors[author] ||= { name: author, commits: 0, additions: 0, deletions: 0, lines: 0, prs: 0 }
719
+ contributors[author][:lines] = lines
720
+ end
721
+ end
722
+
723
+ # Add PR stats
724
+ if report[:pull_requests]
725
+ report[:pull_requests].each do |author, data|
726
+ contributors[author] ||= { name: author, commits: 0, additions: 0, deletions: 0, lines: 0, prs: 0 }
727
+ contributors[author][:prs] = data[:total_prs]
728
+ end
729
+ end
730
+
731
+ contributors.values.sort_by { |c| -c[:commits] }
732
+ end
733
+
734
+ def format_time(time_str)
735
+ return "N/A" unless time_str
736
+ Time.parse(time_str).strftime("%B %d, %Y at %I:%M %p")
737
+ rescue
738
+ time_str
739
+ end
740
+
741
+ def format_number(num)
742
+ return "0" unless num
743
+ num.to_s.reverse.scan(/\d{1,3}/).join(',').reverse
744
+ end
745
+ end
746
+ end
747
+ end