github-daily-digest 0.1.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,1297 @@
1
+ #!/usr/bin/env ruby
2
+ # github_daily_digest/lib/html_formatter.rb
3
+
4
+ module GithubDailyDigest
5
+ class HtmlFormatter
6
+ attr_reader :data, :output_file, :theme
7
+
8
+ def initialize(data, options = {})
9
+ @data = normalize_data(data)
10
+ @output_file = options[:output_file] || generate_default_filename
11
+ @theme = options[:theme] || 'default' # Can be 'default', 'dark', or 'light'
12
+ @chart_theme = options[:chart_theme] || 'default' # Can customize chart colors
13
+ @title = options[:title] || "GitHub Daily Digest - #{Time.now.strftime('%Y-%m-%d')}"
14
+ @show_charts = options.key?(:show_charts) ? options[:show_charts] : true
15
+ @show_extended = options.key?(:show_extended) ? options[:show_extended] : true
16
+ @show_repo_details = options.key?(:show_repo_details) ? options[:show_repo_details] : true
17
+ @show_language_details = options.key?(:show_language_details) ? options[:show_language_details] : true
18
+ @show_work_details = options.key?(:show_work_details) ? options[:show_work_details] : true
19
+ @raw_data = options[:raw_data] # Store raw data for later use
20
+ end
21
+
22
+ def generate
23
+ html_content = build_html
24
+ File.write(output_file, html_content)
25
+ puts "HTML report generated at: #{output_file}"
26
+ output_file
27
+ end
28
+
29
+ private
30
+
31
+ def generate_default_filename
32
+ date = Time.now.strftime('%Y-%m-%d')
33
+ "github_digest_#{date}.html"
34
+ end
35
+
36
+ def build_html
37
+ # Generate charts section conditionally
38
+ charts_section = ""
39
+ if @show_charts
40
+ charts_section = <<-CHARTS
41
+ <div class="card p-6 mb-8">
42
+ <h2 class="text-2xl font-bold mb-6">Contribution Charts</h2>
43
+
44
+ <!-- User Activity Chart -->
45
+ <div class="chart-container">
46
+ <canvas id="userActivityChart"></canvas>
47
+ </div>
48
+
49
+ <!-- Contribution Weights Chart -->
50
+ <div class="chart-container">
51
+ <canvas id="contributionWeightsChart"></canvas>
52
+ </div>
53
+
54
+ <!-- Language Distribution Chart (Combined) -->
55
+ <div class="chart-container">
56
+ <canvas id="combinedLanguageDistributionChart"></canvas>
57
+ </div>
58
+ </div>
59
+ CHARTS
60
+ end
61
+
62
+ # Main HTML template
63
+ template = <<-HTML
64
+ <!DOCTYPE html>
65
+ <html lang="en">
66
+ <head>
67
+ <meta charset="UTF-8">
68
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
69
+ <title>#{@title}</title>
70
+ <!-- Inter font -->
71
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap">
72
+ <!-- Tailwind CSS via CDN -->
73
+ <script src="https://cdn.tailwindcss.com"></script>
74
+ <!-- Chart.js -->
75
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
76
+ <script>
77
+ tailwind.config = {
78
+ darkMode: '#{@theme == 'dark' ? 'class' : 'media'}',
79
+ theme: {
80
+ extend: {
81
+ colors: {
82
+ primary: '#{primary_color}',
83
+ secondary: '#{secondary_color}',
84
+ accent: '#{accent_color}'
85
+ },
86
+ fontFamily: {
87
+ sans: ['Inter', 'system-ui', 'sans-serif'],
88
+ }
89
+ }
90
+ }
91
+ }
92
+ </script>
93
+ <style>
94
+ body {
95
+ font-family: 'Inter', sans-serif;
96
+ }
97
+
98
+ .chart-container {
99
+ height: 400px;
100
+ margin-bottom: 2rem;
101
+ }
102
+
103
+ .card {
104
+ @apply bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden;
105
+ }
106
+
107
+ .badge {
108
+ @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
109
+ }
110
+
111
+ .stat-number {
112
+ @apply text-3xl font-bold;
113
+ }
114
+
115
+ .user-row-details {
116
+ display: none;
117
+ }
118
+
119
+ .user-row.expanded .user-row-details {
120
+ display: table-row;
121
+ }
122
+
123
+ @media print {
124
+ body {
125
+ font-size: 12px;
126
+ }
127
+ .no-print {
128
+ display: none !important;
129
+ }
130
+ .user-row-details {
131
+ display: table-row;
132
+ }
133
+ .card {
134
+ box-shadow: none;
135
+ border: 1px solid #eee;
136
+ }
137
+ .chart-container {
138
+ height: 300px;
139
+ page-break-inside: avoid;
140
+ }
141
+ table {
142
+ page-break-inside: auto;
143
+ }
144
+ tr {
145
+ page-break-inside: avoid;
146
+ page-break-after: auto;
147
+ }
148
+ }
149
+ </style>
150
+ </head>
151
+ <body class="bg-gray-50 #{@theme == 'dark' ? 'dark' : ''} dark:bg-gray-900 text-gray-900 dark:text-white">
152
+ <!-- Header -->
153
+ <header class="bg-gradient-to-r from-indigo-600 to-indigo-900 py-10">
154
+ <div class="container mx-auto px-4">
155
+ <div class="flex flex-col md:flex-row justify-between items-start md:items-center">
156
+ <h1 class="text-3xl font-bold text-white mb-2">#{@title}</h1>
157
+ <div class="mt-4 md:mt-0 space-x-3">
158
+ <button id="themeToggle" class="bg-white/10 text-white px-4 py-2 rounded-lg hover:bg-white/20 transition">
159
+ Toggle theme
160
+ </button>
161
+ <button id="exportPDF" class="bg-white/10 text-white px-4 py-2 rounded-lg hover:bg-white/20 transition">
162
+ Export PDF
163
+ </button>
164
+ </div>
165
+ </div>
166
+ </div>
167
+ </header>
168
+
169
+ <main class="container mx-auto px-4 py-8">
170
+ <!-- Stats -->
171
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
172
+ <div class="card p-6">
173
+ <p class="text-gray-500 dark:text-gray-400 mb-1 font-medium">Active Users</p>
174
+ <p class="stat-number text-indigo-600 dark:text-indigo-400">#{@data["summary"]["active_users_count"]}</p>
175
+ </div>
176
+ <div class="card p-6">
177
+ <p class="text-gray-500 dark:text-gray-400 mb-1 font-medium">Total Commits</p>
178
+ <p class="stat-number text-green-600 dark:text-green-400">#{@data["summary"]["total_commits"]}</p>
179
+ </div>
180
+ <div class="card p-6">
181
+ <p class="text-gray-500 dark:text-gray-400 mb-1 font-medium">Total PRs</p>
182
+ <p class="stat-number text-purple-600 dark:text-purple-400">#{@data["summary"]["total_pull_requests"]}</p>
183
+ </div>
184
+ </div>
185
+
186
+ <!-- Charts -->
187
+ #{charts_section}
188
+
189
+ <!-- Users Activity -->
190
+ <div class="card p-6 mb-8">
191
+ <h2 class="text-2xl font-bold mb-6">Team Activity</h2>
192
+ <div class="overflow-x-auto">
193
+ <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
194
+ <thead>
195
+ <tr>
196
+ <th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">User</th>
197
+ <th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Organization</th>
198
+ <th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Lines Changed</th>
199
+ <th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Contribution Weights</th>
200
+ <th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Total Score</th>
201
+ <th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Details</th>
202
+ </tr>
203
+ </thead>
204
+ <tbody>
205
+ #{generate_user_rows}
206
+ </tbody>
207
+ </table>
208
+ </div>
209
+ </div>
210
+
211
+ <!-- Summary Dashboard -->
212
+ <div class="flex justify-between items-center mb-6">
213
+ <h1 class="text-2xl font-bold">#{@title || 'GitHub Daily Digest Dashboard'}</h1>
214
+ <div class="flex space-x-2">
215
+ <button id="theme-toggle" class="p-2 rounded-md bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200">
216
+ <svg xmlns="http://www.w3.org/2000/svg" class="icon-light hidden h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
217
+ <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
218
+ </svg>
219
+ <svg xmlns="http://www.w3.org/2000/svg" class="icon-dark h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
220
+ <path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd" />
221
+ </svg>
222
+ </button>
223
+ </div>
224
+ </div>
225
+
226
+ <!-- Summary Dashboard -->
227
+ <div class="card bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
228
+ <h2 class="text-2xl font-bold mb-4">Activity Summary</h2>
229
+
230
+ <div class="flex flex-col md:flex-row">
231
+ <!-- Time period filter -->
232
+ <div class="mb-4 md:mr-4">
233
+ <label class="block text-sm font-medium mb-1">Time period</label>
234
+ <div class="inline-block border border-gray-300 dark:border-gray-600 rounded-md">
235
+ <p class="px-3 py-2 text-sm">#{@data["summary_statistics"]&.[]("period") || "Last 7 days"}</p>
236
+ </div>
237
+ </div>
238
+ </div>
239
+
240
+ <!-- Stats Grid -->
241
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mt-4">
242
+ <!-- Total Commits -->
243
+ <div class="stat-card bg-gray-50 dark:bg-gray-700/50 p-4 rounded-lg">
244
+ <h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Commits</h3>
245
+ <div class="mt-2">
246
+ <p class="text-4xl font-bold">#{@data["summary_statistics"]&.[]("total_commits") || 0}</p>
247
+ </div>
248
+ </div>
249
+
250
+ <!-- Lines Changed -->
251
+ <div class="stat-card bg-gray-50 dark:bg-gray-700/50 p-4 rounded-lg">
252
+ <h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Lines Changed</h3>
253
+ <div class="mt-2">
254
+ <p class="text-4xl font-bold">#{@data["summary_statistics"]&.[]("total_lines_changed") || 0}</p>
255
+ </div>
256
+ </div>
257
+
258
+ <!-- Active Developers -->
259
+ <div class="stat-card bg-gray-50 dark:bg-gray-700/50 p-4 rounded-lg">
260
+ <h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Active Developers</h3>
261
+ <div class="mt-2">
262
+ <p class="text-4xl font-bold">#{@data["summary_statistics"]&.[]("active_users_count") || 0}</p>
263
+ </div>
264
+ </div>
265
+
266
+ <!-- Active Repositories -->
267
+ <div class="stat-card bg-gray-50 dark:bg-gray-700/50 p-4 rounded-lg">
268
+ <h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Active Repositories</h3>
269
+ <div class="mt-2">
270
+ <p class="text-4xl font-bold">#{@data["summary_statistics"]&.[]("active_repos_count") || 0}</p>
271
+ </div>
272
+ </div>
273
+ </div>
274
+
275
+ <!-- AI Generated Summary -->
276
+ <div class="mt-6 p-4 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
277
+ <h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">AI Summary</h3>
278
+ <p class="text-gray-800 dark:text-gray-200">#{@data["summary_statistics"]&.[]("ai_summary") || "Team showed varied activity across multiple repositories with good collaborative development."}</p>
279
+ </div>
280
+
281
+ <!-- Advanced Metrics -->
282
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
283
+ <!-- Language Distribution -->
284
+ <div class="bg-gray-50 dark:bg-gray-700/50 p-4 rounded-lg">
285
+ <h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-3">Language Distribution</h3>
286
+ <div class="h-64">
287
+ <canvas id="summaryLanguageChart"></canvas>
288
+ </div>
289
+ </div>
290
+
291
+ <!-- Average Contribution Weights -->
292
+ <div class="bg-gray-50 dark:bg-gray-700/50 p-4 rounded-lg">
293
+ <h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-3">Contribution Metrics (Avg)</h3>
294
+ <div class="h-64">
295
+ <canvas id="avgMetricsChart"></canvas>
296
+ </div>
297
+ </div>
298
+ </div>
299
+ </div>
300
+
301
+ <!-- Organizations -->
302
+ #{generate_org_sections}
303
+ </main>
304
+
305
+ <script>
306
+ // Data for charts
307
+ const userData = #{generate_user_chart_data.to_json};
308
+ const contributionWeightsData = #{generate_contribution_weights_data.to_json};
309
+ const languageDistributionData = #{generate_language_distribution_data.to_json};
310
+ const combinedLanguageData = #{generate_combined_language_data.to_json};
311
+
312
+ document.addEventListener('DOMContentLoaded', function() {
313
+ // Create User Activity chart
314
+ try {
315
+ const activityCtx = document.getElementById('userActivityChart');
316
+ if (activityCtx) {
317
+ const userLabels = userData.map(user => user.name);
318
+ const commitData = userData.map(user => user.commits);
319
+ const prData = userData.map(user => user.prs);
320
+ const reviewData = userData.map(user => user.reviews);
321
+
322
+ if (userLabels.length > 0) {
323
+ new Chart(activityCtx, {
324
+ type: 'bar',
325
+ data: {
326
+ labels: userLabels,
327
+ datasets: [
328
+ {
329
+ label: 'Commits',
330
+ data: commitData,
331
+ backgroundColor: '#4f46e5',
332
+ borderColor: '#4338ca',
333
+ borderWidth: 1
334
+ },
335
+ {
336
+ label: 'PRs',
337
+ data: prData,
338
+ backgroundColor: '#10b981',
339
+ borderColor: '#059669',
340
+ borderWidth: 1
341
+ },
342
+ {
343
+ label: 'Reviews',
344
+ data: reviewData,
345
+ backgroundColor: '#f59e0b',
346
+ borderColor: '#d97706',
347
+ borderWidth: 1
348
+ }
349
+ ]
350
+ },
351
+ options: {
352
+ responsive: true,
353
+ maintainAspectRatio: false,
354
+ scales: {
355
+ y: {
356
+ beginAtZero: true
357
+ }
358
+ },
359
+ plugins: {
360
+ title: {
361
+ display: true,
362
+ text: 'User Activity'
363
+ },
364
+ legend: {
365
+ position: 'top'
366
+ }
367
+ }
368
+ }
369
+ });
370
+ } else {
371
+ // Create empty placeholder chart when no data is available
372
+ activityCtx.parentNode.innerHTML = '<div class="flex items-center justify-center h-full w-full text-gray-400">No activity data available</div>';
373
+ }
374
+ }
375
+
376
+ // Create Contribution Weights radar chart
377
+ const weightsCtx = document.getElementById('contributionWeightsChart');
378
+ if (weightsCtx && contributionWeightsData.length > 0) {
379
+ new Chart(weightsCtx, {
380
+ type: 'radar',
381
+ data: {
382
+ labels: ['Lines of Code', 'Complexity', 'Technical Depth', 'Scope', 'PR Reviews'],
383
+ datasets: contributionWeightsData.map((user, index) => {
384
+ // Generate a color based on index
385
+ const hue = (index * 137) % 360; // Golden angle approximation for good distribution
386
+ const color = `hsla(${hue}, 70%, 60%, 0.7)`;
387
+ const borderColor = `hsla(${hue}, 70%, 50%, 1)`;
388
+
389
+ return {
390
+ label: user.name,
391
+ data: [
392
+ user.weights.lines_of_code,
393
+ user.weights.complexity,
394
+ user.weights.technical_depth,
395
+ user.weights.scope,
396
+ user.weights.pr_reviews
397
+ ],
398
+ backgroundColor: color,
399
+ borderColor: borderColor,
400
+ borderWidth: 2,
401
+ pointBackgroundColor: borderColor,
402
+ pointRadius: 3
403
+ };
404
+ })
405
+ },
406
+ options: {
407
+ responsive: true,
408
+ maintainAspectRatio: false,
409
+ scales: {
410
+ r: {
411
+ angleLines: {
412
+ display: true
413
+ },
414
+ suggestedMin: 0,
415
+ suggestedMax: 10
416
+ }
417
+ },
418
+ plugins: {
419
+ title: {
420
+ display: true,
421
+ text: 'Contribution Weights (0-10 scale)'
422
+ },
423
+ legend: {
424
+ position: 'top'
425
+ }
426
+ }
427
+ }
428
+ });
429
+ } else {
430
+ // Create empty placeholder chart when no data is available
431
+ weightsCtx.parentNode.innerHTML = '<div class="flex items-center justify-center h-full w-full text-gray-400">No contribution weights data available</div>';
432
+ }
433
+
434
+ // Create Language Distribution combined chart
435
+ const combinedLangCtx = document.getElementById('combinedLanguageDistributionChart');
436
+ if (combinedLangCtx) {
437
+ if (combinedLanguageData.labels && combinedLanguageData.labels.length > 0) {
438
+ new Chart(combinedLangCtx, {
439
+ type: 'doughnut',
440
+ data: {
441
+ labels: combinedLanguageData.labels,
442
+ datasets: [{
443
+ data: combinedLanguageData.data,
444
+ backgroundColor: combinedLanguageData.labels.map((_, i) => {
445
+ const hue = (i * 137) % 360;
446
+ return `hsla(${hue}, 70%, 60%, 0.7)`;
447
+ }),
448
+ borderWidth: 1
449
+ }]
450
+ },
451
+ options: {
452
+ responsive: true,
453
+ maintainAspectRatio: false,
454
+ plugins: {
455
+ title: {
456
+ display: true,
457
+ text: 'Team Language Distribution'
458
+ },
459
+ legend: {
460
+ position: 'right',
461
+ labels: {
462
+ boxWidth: 15
463
+ }
464
+ },
465
+ tooltip: {
466
+ callbacks: {
467
+ label: function(context) {
468
+ const label = context.label || '';
469
+ const value = context.raw || 0;
470
+ return `${label}: ${value.toFixed(1)}%`;
471
+ }
472
+ }
473
+ }
474
+ }
475
+ }
476
+ });
477
+ } else {
478
+ // Create empty placeholder chart when no data is available
479
+ combinedLangCtx.parentNode.innerHTML = '<div class="flex items-center justify-center h-full w-full text-gray-400">No language data available</div>';
480
+ }
481
+ }
482
+
483
+ // Theme toggle
484
+ document.getElementById('themeToggle').addEventListener('click', function() {
485
+ document.documentElement.classList.toggle('dark');
486
+ });
487
+
488
+ // PDF Export
489
+ document.getElementById('exportPDF').addEventListener('click', function() {
490
+ window.print();
491
+ });
492
+
493
+ // Toggle user details
494
+ document.querySelectorAll('.toggle-details').forEach(button => {
495
+ button.addEventListener('click', function() {
496
+ const userRow = this.closest('tr');
497
+ userRow.classList.toggle('expanded');
498
+ this.textContent = userRow.classList.contains('expanded') ? 'Hide' : 'Show';
499
+ });
500
+ });
501
+
502
+ // Summary statistics
503
+ const summaryStats = #{(@data["summary_statistics"] || {}).to_json};
504
+
505
+ // Create summary language distribution chart
506
+ const summaryLangCtx = document.getElementById('summaryLanguageChart');
507
+ if (summaryLangCtx && summaryStats && summaryStats.team_language_distribution) {
508
+ const langData = summaryStats.team_language_distribution || {};
509
+ const langLabels = Object.keys(langData);
510
+ const langValues = Object.values(langData);
511
+
512
+ if (langLabels.length > 0) {
513
+ new Chart(summaryLangCtx, {
514
+ type: 'doughnut',
515
+ data: {
516
+ labels: langLabels,
517
+ datasets: [{
518
+ data: langValues,
519
+ backgroundColor: langLabels.map((_, i) => {
520
+ const hue = (i * 137) % 360;
521
+ return `hsla(${hue}, 70%, 60%, 0.7)`;
522
+ }),
523
+ borderWidth: 1
524
+ }]
525
+ },
526
+ options: {
527
+ responsive: true,
528
+ maintainAspectRatio: false,
529
+ plugins: {
530
+ legend: {
531
+ position: 'right',
532
+ labels: {
533
+ boxWidth: 15,
534
+ color: document.documentElement.classList.contains('dark') ? '#e5e7eb' : '#374151'
535
+ }
536
+ },
537
+ tooltip: {
538
+ callbacks: {
539
+ label: function(context) {
540
+ const label = context.label || '';
541
+ const value = context.raw || 0;
542
+ return `${label}: ${value.toFixed(1)}%`;
543
+ }
544
+ }
545
+ }
546
+ }
547
+ }
548
+ });
549
+ } else {
550
+ summaryLangCtx.parentNode.innerHTML = '<div class="flex items-center justify-center h-full w-full text-gray-400">No language data available</div>';
551
+ }
552
+ } else if (summaryLangCtx) {
553
+ summaryLangCtx.parentNode.innerHTML = '<div class="flex items-center justify-center h-full w-full text-gray-400">No language data available</div>';
554
+ }
555
+
556
+ // Average Metrics chart
557
+ const avgMetricsCtx = document.getElementById('avgMetricsChart');
558
+ if (avgMetricsCtx && summaryStats && summaryStats.average_weights) {
559
+ const weights = summaryStats.average_weights || {};
560
+ const metricLabels = [
561
+ 'Lines of Code',
562
+ 'Complexity',
563
+ 'Technical Depth',
564
+ 'Scope',
565
+ 'PR Reviews'
566
+ ];
567
+ const metricValues = [
568
+ weights.lines_of_code || 0,
569
+ weights.complexity || 0,
570
+ weights.technical_depth || 0,
571
+ weights.scope || 0,
572
+ weights.pr_reviews || 0
573
+ ];
574
+
575
+ new Chart(avgMetricsCtx, {
576
+ type: 'bar',
577
+ data: {
578
+ labels: metricLabels,
579
+ datasets: [{
580
+ label: 'Average Score (0-10)',
581
+ data: metricValues,
582
+ backgroundColor: [
583
+ 'rgba(99, 102, 241, 0.7)', // Indigo
584
+ 'rgba(16, 185, 129, 0.7)', // Green
585
+ 'rgba(245, 158, 11, 0.7)', // Amber
586
+ 'rgba(236, 72, 153, 0.7)', // Pink
587
+ 'rgba(79, 70, 229, 0.7)' // Blue
588
+ ],
589
+ borderColor: [
590
+ 'rgb(79, 70, 229)',
591
+ 'rgb(5, 150, 105)',
592
+ 'rgb(217, 119, 6)',
593
+ 'rgb(219, 39, 119)',
594
+ 'rgb(67, 56, 202)'
595
+ ],
596
+ borderWidth: 1
597
+ }]
598
+ },
599
+ options: {
600
+ responsive: true,
601
+ maintainAspectRatio: false,
602
+ scales: {
603
+ y: {
604
+ beginAtZero: true,
605
+ max: 10,
606
+ grid: {
607
+ color: document.documentElement.classList.contains('dark') ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'
608
+ },
609
+ ticks: {
610
+ color: document.documentElement.classList.contains('dark') ? '#e5e7eb' : '#374151'
611
+ }
612
+ },
613
+ x: {
614
+ grid: {
615
+ color: document.documentElement.classList.contains('dark') ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'
616
+ },
617
+ ticks: {
618
+ color: document.documentElement.classList.contains('dark') ? '#e5e7eb' : '#374151'
619
+ }
620
+ }
621
+ },
622
+ plugins: {
623
+ legend: {
624
+ display: false
625
+ }
626
+ }
627
+ }
628
+ });
629
+ } else if (avgMetricsCtx) {
630
+ avgMetricsCtx.parentNode.innerHTML = '<div class="flex items-center justify-center h-full w-full text-gray-400">No metrics data available</div>';
631
+ }
632
+ } catch (error) {
633
+ console.error('Error creating charts:', error);
634
+ }
635
+ });
636
+ </script>
637
+ </body>
638
+ </html>
639
+ HTML
640
+
641
+ template
642
+ end
643
+
644
+ def generate_contribution_weights_data
645
+ return [] unless @data["active_users"] && !@data["active_users"].empty?
646
+
647
+ # Get top contributors (limit to 5 for radar chart readability)
648
+ @data["active_users"].select { |user| user["contribution_weights"] && !user["contribution_weights"].empty? }
649
+ .sort_by { |u| -(u["total_score"] || 0) }
650
+ .take(5)
651
+ .map do |user|
652
+ weights = user["contribution_weights"] || {}
653
+
654
+ # Handle both string and symbol keys to be more robust
655
+ {
656
+ name: user["username"] || user["login"],
657
+ weights: {
658
+ lines_of_code: weights["lines_of_code"].to_i || weights[:lines_of_code].to_i || 0,
659
+ complexity: weights["complexity"].to_i || weights[:complexity].to_i || 0,
660
+ technical_depth: weights["technical_depth"].to_i || weights[:technical_depth].to_i || 0,
661
+ scope: weights["scope"].to_i || weights[:scope].to_i || 0,
662
+ pr_reviews: weights["pr_reviews"].to_i || weights[:pr_reviews].to_i || 0
663
+ }
664
+ }
665
+ end
666
+ end
667
+
668
+ def generate_user_rows
669
+ return "" unless @data["active_users"] && !@data["active_users"].empty?
670
+
671
+ rows = []
672
+ @data["active_users"].each do |user|
673
+ username = user["username"] || user["login"]
674
+ weights = user["contribution_weights"] || {}
675
+
676
+ loc_weight = weights["lines_of_code"].to_i
677
+ complexity_weight = weights["complexity"].to_i
678
+ depth_weight = weights["technical_depth"].to_i
679
+ scope_weight = weights["scope"].to_i
680
+ pr_weight = weights["pr_reviews"].to_i
681
+
682
+ total_score = user["total_score"] || (loc_weight + complexity_weight + depth_weight + scope_weight + pr_weight)
683
+
684
+ avatar_url = user["avatar_url"] || "https://ui-avatars.com/api/?name=#{username}&background=random"
685
+
686
+ # Extract user activity details
687
+ work_details = extract_user_work_details(username)
688
+
689
+ # Create weight badges
690
+ weight_badges = [
691
+ "<span class=\"badge bg-gray-100 dark:bg-gray-700 mr-1\">LOC: <span class=\"#{weight_color_class(loc_weight)}\">#{loc_weight}</span></span>",
692
+ "<span class=\"badge bg-gray-100 dark:bg-gray-700 mr-1\">Complex: <span class=\"#{weight_color_class(complexity_weight)}\">#{complexity_weight}</span></span>",
693
+ "<span class=\"badge bg-gray-100 dark:bg-gray-700 mr-1\">Depth: <span class=\"#{weight_color_class(depth_weight)}\">#{depth_weight}</span></span>",
694
+ "<span class=\"badge bg-gray-100 dark:bg-gray-700 mr-1\">Scope: <span class=\"#{weight_color_class(scope_weight)}\">#{scope_weight}</span></span>",
695
+ "<span class=\"badge bg-gray-100 dark:bg-gray-700\">PR: <span class=\"#{weight_color_class(pr_weight)}\">#{pr_weight}</span></span>"
696
+ ].join
697
+
698
+ # Create organization badge
699
+ org_name = user["organization"] || "Unknown"
700
+ organization_badge = "<span class=\"badge bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200\">#{org_name}</span>"
701
+
702
+ # Create repository badges
703
+ repo_badges = ""
704
+ if @show_repo_details && work_details[:repos] && !work_details[:repos].empty?
705
+ repo_badges = work_details[:repos].map do |repo|
706
+ "<span class=\"badge bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 mr-1\">#{repo[:name]}</span>"
707
+ end.join
708
+ end
709
+
710
+ # Language badges
711
+ language_badges = ""
712
+ if @show_language_details && work_details[:language_distribution] && !work_details[:language_distribution].empty?
713
+ language_badges = work_details[:language_distribution].map do |lang, percent|
714
+ "<span class=\"badge bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 mr-1\">#{lang}: #{percent.round(1)}%</span>"
715
+ end.join
716
+ end
717
+
718
+ # Generate detailed work info
719
+ work_details_html = ""
720
+ if @show_work_details
721
+ commits_html = ""
722
+ if work_details[:commits] && !work_details[:commits].empty?
723
+ commits_html = <<-HTML
724
+ <div class="mb-4">
725
+ <h5 class="font-semibold mb-2">Recent Commits</h5>
726
+ <ul class="list-disc pl-5">
727
+ #{work_details[:commits].map { |c| "<li>#{c[:message]}</li>" }.join("\n")}
728
+ </ul>
729
+ </div>
730
+ HTML
731
+ end
732
+
733
+ prs_html = ""
734
+ if work_details[:prs] && !work_details[:prs].empty?
735
+ prs_html = <<-HTML
736
+ <div class="mb-4">
737
+ <h5 class="font-semibold mb-2">Pull Requests</h5>
738
+ <ul class="list-disc pl-5">
739
+ #{work_details[:prs].map { |pr| "<li>#{pr[:title]} <span class=\"text-xs text-gray-500\">(#{pr[:state]})</span></li>" }.join("\n")}
740
+ </ul>
741
+ </div>
742
+ HTML
743
+ end
744
+
745
+ work_details_html = commits_html + prs_html
746
+ end
747
+
748
+ # Determine if details should be initially expanded based on settings
749
+ expanded_class = @show_extended ? " expanded" : ""
750
+ button_text = @show_extended ? "Hide" : "Show"
751
+
752
+ user_row = <<-ROW
753
+ <tr class="user-row hover:bg-gray-50 dark:hover:bg-gray-900/60#{expanded_class}">
754
+ <td class="px-4 py-4">
755
+ <div class="flex items-center">
756
+ <img src="#{avatar_url}" alt="#{username}" class="w-8 h-8 rounded-full mr-3">
757
+ <span class="font-medium">#{username}</span>
758
+ </div>
759
+ </td>
760
+ <td class="px-4 py-4">#{organization_badge}</td>
761
+ <td class="px-4 py-4">#{user["lines_changed"] || 0}</td>
762
+ <td class="px-4 py-4">#{weight_badges}</td>
763
+ <td class="px-4 py-4"><span class="font-bold text-lg #{score_color_class(total_score)}">#{total_score}</span></td>
764
+ <td class="px-4 py-4">
765
+ <button class="toggle-details px-3 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
766
+ #{button_text}
767
+ </button>
768
+ </td>
769
+ </tr>
770
+ <tr class="user-row-details bg-gray-50 dark:bg-gray-800/50" data-username="#{username}">
771
+ <td colspan="6" class="px-6 py-4">
772
+ <div class="text-sm">
773
+ <h4 class="font-medium mb-2">Activity Details</h4>
774
+
775
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
776
+ <div>
777
+ <h5 class="font-semibold mb-2">Repositories</h5>
778
+ <div class="flex flex-wrap gap-1 mb-2">
779
+ #{repo_badges}
780
+ </div>
781
+ </div>
782
+
783
+ <div>
784
+ <h5 class="font-semibold mb-2">Languages</h5>
785
+ <div class="flex flex-wrap gap-1">
786
+ #{language_badges}
787
+ </div>
788
+ </div>
789
+ </div>
790
+
791
+ #{work_details_html}
792
+ </div>
793
+ </td>
794
+ </tr>
795
+ ROW
796
+
797
+ rows << user_row
798
+ end
799
+
800
+ rows.join("\n")
801
+ end
802
+
803
+ def generate_user_chart_data
804
+ return [] unless @data["active_users"] && !@data["active_users"].empty?
805
+
806
+ @data["active_users"].map do |user|
807
+ {
808
+ name: user["username"] || user["login"],
809
+ commits: user["commits_count"] || user["commit_count"] || 0,
810
+ prs: user["prs_count"] || user["pr_count"] || 0,
811
+ reviews: user["reviews_count"] || user["review_count"] || 0
812
+ }
813
+ end
814
+ end
815
+
816
+ def generate_language_distribution_data
817
+ return [] unless @data["active_users"] && !@data["active_users"].empty?
818
+
819
+ @data["active_users"].map do |user|
820
+ # Skip users without language distribution data
821
+ next unless user["language_distribution"] && !user["language_distribution"].empty?
822
+
823
+ username = user["username"] || user["login"]
824
+ {
825
+ name: username,
826
+ languages: user["language_distribution"].map do |lang, percentage|
827
+ {
828
+ name: lang.to_s,
829
+ percentage: percentage.to_f
830
+ }
831
+ end
832
+ }
833
+ end.compact
834
+ end
835
+
836
+ def generate_combined_language_data
837
+ return { labels: [], data: [] } unless @data["active_users"] && !@data["active_users"].empty?
838
+
839
+ labels = []
840
+ data = []
841
+ @data["active_users"].each do |user|
842
+ next unless user["language_distribution"] && !user["language_distribution"].empty?
843
+
844
+ user["language_distribution"].each do |lang, percentage|
845
+ index = labels.find_index(lang)
846
+ if index
847
+ data[index] += percentage.to_f
848
+ else
849
+ labels << lang
850
+ data << percentage.to_f
851
+ end
852
+ end
853
+ end
854
+
855
+ { labels: labels, data: data }
856
+ end
857
+
858
+ def weight_color_class(weight)
859
+ case weight
860
+ when 0..3
861
+ "text-blue-500 dark:text-blue-400"
862
+ when 4..6
863
+ "text-indigo-600 dark:text-indigo-400"
864
+ when 7..8
865
+ "text-purple-600 dark:text-purple-500"
866
+ when 9..10
867
+ "text-red-600 dark:text-red-500"
868
+ else
869
+ "text-gray-600 dark:text-gray-400"
870
+ end
871
+ end
872
+
873
+ def extract_user_work_details(username)
874
+ details = {
875
+ repos: [],
876
+ commits: [],
877
+ prs: [],
878
+ language_distribution: {}
879
+ }
880
+
881
+ # Extract repository work
882
+ return details unless @data["organizations"] && !@data["organizations"].empty?
883
+
884
+ # First, check if we have language distribution data for this user
885
+ @data["active_users"]&.each do |user|
886
+ if (user["username"] == username || user["login"] == username) && user["language_distribution"]
887
+ details[:language_distribution] = user["language_distribution"]
888
+ break
889
+ end
890
+ end
891
+
892
+ @data["organizations"].each do |org|
893
+ next unless org["repositories"] && !org["repositories"].empty?
894
+
895
+ org["repositories"].each do |repo|
896
+ # Add to repos if the user worked in this repo
897
+ user_commits = repo["commits"]&.select { |c| (c["author"]&.downcase == username.downcase) || (c["committer"]&.downcase == username.downcase) }
898
+ user_prs = repo["pull_requests"]&.select { |pr| pr["user"]&.downcase == username.downcase }
899
+
900
+ if (user_commits && !user_commits.empty?) || (user_prs && !user_prs.empty?)
901
+ details[:repos] << {
902
+ name: repo["name"],
903
+ url: repo["url"] || "https://github.com/#{org["name"]}/#{repo["name"]}"
904
+ }
905
+ end
906
+
907
+ # Add commits
908
+ if user_commits && !user_commits.empty?
909
+ details[:commits] += user_commits.map do |c|
910
+ {
911
+ message: c["message"]&.split("\n")&.first || "No message",
912
+ url: c["url"] || "#",
913
+ sha: c["sha"] || c["id"] || "Unknown"
914
+ }
915
+ end.take(5) # Just show the last 5 commits
916
+ end
917
+
918
+ # Add PRs
919
+ if user_prs && !user_prs.empty?
920
+ details[:prs] += user_prs.map do |pr|
921
+ {
922
+ title: pr["title"] || "No title",
923
+ url: pr["url"] || "#",
924
+ state: pr["state"] || "unknown",
925
+ number: pr["number"] || "#"
926
+ }
927
+ end.take(5) # Just show the last 5 PRs
928
+ end
929
+ end
930
+ end
931
+
932
+ # Sort and limit
933
+ details[:commits] = details[:commits].sort_by { |c| c[:sha] }.reverse.take(5)
934
+ details[:prs] = details[:prs].sort_by { |pr| pr[:number].to_s }.reverse.take(5)
935
+ details[:repos] = details[:repos].uniq { |r| r[:name] }
936
+
937
+ details
938
+ end
939
+
940
+ def score_color_class(score)
941
+ case score
942
+ when 0..15
943
+ "text-yellow-600 dark:text-yellow-400"
944
+ when 16..30
945
+ "text-green-600 dark:text-green-400"
946
+ when 31..40
947
+ "text-blue-600 dark:text-blue-400"
948
+ else
949
+ "text-purple-600 dark:text-purple-400"
950
+ end
951
+ end
952
+
953
+ def primary_color
954
+ case @theme
955
+ when 'dark'
956
+ '#6366f1' # Indigo
957
+ when 'light'
958
+ '#4f46e5' # Indigo
959
+ else
960
+ '#4f46e5' # Default (Indigo)
961
+ end
962
+ end
963
+
964
+ def secondary_color
965
+ case @theme
966
+ when 'dark'
967
+ '#10b981' # Emerald
968
+ when 'light'
969
+ '#10b981' # Emerald
970
+ else
971
+ '#10b981' # Default (Emerald)
972
+ end
973
+ end
974
+
975
+ def accent_color
976
+ case @theme
977
+ when 'dark'
978
+ '#f59e0b' # Amber
979
+ when 'light'
980
+ '#f59e0b' # Amber
981
+ else
982
+ '#f59e0b' # Default (Amber)
983
+ end
984
+ end
985
+
986
+ def theme_class
987
+ @theme == 'dark' ? 'dark bg-gray-800 text-white' : 'bg-gray-100 text-gray-800'
988
+ end
989
+
990
+ def dark_class
991
+ @theme == 'dark' ? 'dark:bg-gray-800 dark:text-white' : ''
992
+ end
993
+
994
+ def text_color
995
+ case @theme
996
+ when 'dark'
997
+ 'text-white'
998
+ else
999
+ 'text-gray-800'
1000
+ end
1001
+ end
1002
+
1003
+ def generate_org_sections
1004
+ return "" unless @data["organizations"] && !@data["organizations"].empty?
1005
+
1006
+ @data["organizations"].map do |org|
1007
+ <<-ORG_SECTION
1008
+ <div class="card p-6 mb-8">
1009
+ <h2 class="text-2xl font-bold mb-6">#{org["name"]} Organization</h2>
1010
+
1011
+ <div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
1012
+ <div class="bg-gray-50 dark:bg-gray-900/50 p-4 rounded-lg">
1013
+ <p class="text-gray-500 dark:text-gray-400 text-sm font-medium">Commits</p>
1014
+ <p class="text-xl font-bold text-indigo-600 dark:text-indigo-400">#{org["total_commits"] || 0}</p>
1015
+ </div>
1016
+ <div class="bg-gray-50 dark:bg-gray-900/50 p-4 rounded-lg">
1017
+ <p class="text-gray-500 dark:text-gray-400 text-sm font-medium">Pull Requests</p>
1018
+ <p class="text-xl font-bold text-purple-600 dark:text-purple-400">#{org["total_pull_requests"] || 0}</p>
1019
+ </div>
1020
+ <div class="bg-gray-50 dark:bg-gray-900/50 p-4 rounded-lg">
1021
+ <p class="text-gray-500 dark:text-gray-400 text-sm font-medium">Reviews</p>
1022
+ <p class="text-xl font-bold text-green-600 dark:text-green-400">#{org["total_reviews"] || 0}</p>
1023
+ </div>
1024
+ <div class="bg-gray-50 dark:bg-gray-900/50 p-4 rounded-lg">
1025
+ <p class="text-gray-500 dark:text-gray-400 text-sm font-medium">Active Users</p>
1026
+ <p class="text-xl font-bold text-blue-600 dark:text-blue-400">#{org["active_users_count"] || 0}</p>
1027
+ </div>
1028
+ </div>
1029
+
1030
+ <!-- Repository Activity Section -->
1031
+ #{generate_repo_section(org)}
1032
+ </div>
1033
+ ORG_SECTION
1034
+ end.join("\n")
1035
+ end
1036
+
1037
+ def generate_repo_section(org)
1038
+ return "" unless org["repositories"] && !org["repositories"].empty?
1039
+
1040
+ <<-REPO_SECTION
1041
+ <div>
1042
+ <h3 class="text-xl font-semibold mb-4">Repository Activity</h3>
1043
+ <div class="overflow-x-auto">
1044
+ <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
1045
+ <thead>
1046
+ <tr>
1047
+ <th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Repository</th>
1048
+ <th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Commits</th>
1049
+ <th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">PRs</th>
1050
+ <th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Reviews</th>
1051
+ </tr>
1052
+ </thead>
1053
+ <tbody class="divide-y divide-gray-200 dark:divide-gray-700">
1054
+ #{generate_repo_rows(org["repositories"])}
1055
+ </tbody>
1056
+ </table>
1057
+ </div>
1058
+ </div>
1059
+ REPO_SECTION
1060
+ end
1061
+
1062
+ def generate_repo_rows(repositories)
1063
+ repositories.map do |repo|
1064
+ repo_url = repo["url"] || ""
1065
+ <<-REPO_ROW
1066
+ <tr class="hover:bg-gray-50 dark:hover:bg-gray-900/60">
1067
+ <td class="px-4 py-4">
1068
+ <div>
1069
+ <span class="font-medium">#{repo["name"]}</span>
1070
+ #{repo_url.empty? ? "" : "<a href=\"#{repo_url}\" class=\"text-xs text-blue-600 dark:text-blue-400 hover:underline block mt-1\">#{repo_url}</a>"}
1071
+ </div>
1072
+ </td>
1073
+ <td class="px-4 py-4">#{repo["commit_count"] || 0}</td>
1074
+ <td class="px-4 py-4">#{repo["pr_count"] || 0}</td>
1075
+ <td class="px-4 py-4">#{repo["review_count"] || 0}</td>
1076
+ </tr>
1077
+ REPO_ROW
1078
+ end.join("\n")
1079
+ end
1080
+
1081
+ # Normalize the data structure to ensure all expected fields exist
1082
+ def normalize_data(data)
1083
+ # Handle top-level structure, which might be different depending on the source
1084
+ normalized = {}
1085
+
1086
+ # If data is organized by org (most common case)
1087
+ if data.is_a?(Hash) && data.values.first.is_a?(Hash) && !data['summary']
1088
+ # Extract first org's data as the default view
1089
+ first_org = data.values.first
1090
+
1091
+ # Create summary from the first org's meta data
1092
+ normalized["summary"] = {
1093
+ "active_users_count" =>
1094
+ if first_org.key?("_meta") && first_org["_meta"].is_a?(Hash) && first_org["_meta"]["active_users_count"]
1095
+ first_org["_meta"]["active_users_count"]
1096
+ else
1097
+ first_org.values.select { |v| v.is_a?(Hash) }.count
1098
+ end,
1099
+ "total_commits" =>
1100
+ if first_org.key?("_meta") && first_org["_meta"].is_a?(Hash) && first_org["_meta"]["total_commits"]
1101
+ first_org["_meta"]["total_commits"]
1102
+ elsif first_org.key?("_meta") && first_org["_meta"].is_a?(Hash) &&
1103
+ first_org["_meta"]["repo_stats"].is_a?(Array)
1104
+ first_org["_meta"]["repo_stats"].sum { |r| r["total_commits"].to_i }
1105
+ else
1106
+ 0
1107
+ end,
1108
+ "total_pull_requests" =>
1109
+ if first_org.key?("_meta") && first_org["_meta"].is_a?(Hash) && first_org["_meta"]["total_pull_requests"]
1110
+ first_org["_meta"]["total_pull_requests"]
1111
+ elsif first_org.key?("_meta") && first_org["_meta"].is_a?(Hash) &&
1112
+ first_org["_meta"]["repo_stats"].is_a?(Array)
1113
+ first_org["_meta"]["repo_stats"].sum { |r| r["open_prs"].to_i }
1114
+ else
1115
+ 0
1116
+ end
1117
+ }
1118
+
1119
+ # Extract active users
1120
+ normalized["active_users"] = []
1121
+ data.each do |org_name, org_data|
1122
+ next unless org_data.is_a?(Hash)
1123
+
1124
+ org_data.each do |username, user_data|
1125
+ next if username == "_meta" # Skip metadata
1126
+ next unless user_data.is_a?(Hash)
1127
+
1128
+ # Only add the user if they have real activity
1129
+ changes = user_data["changes"].to_i
1130
+ pr_count = user_data["pr_count"].to_i
1131
+
1132
+ if changes > 0 || pr_count > 0
1133
+ # Create a standardized user structure
1134
+ contribution_weights = user_data["contribution_weights"] || {}
1135
+ if contribution_weights.is_a?(Hash)
1136
+ weights = {
1137
+ "lines_of_code" => contribution_weights["lines_of_code"].to_i,
1138
+ "complexity" => contribution_weights["complexity"].to_i,
1139
+ "technical_depth" => contribution_weights["technical_depth"].to_i,
1140
+ "scope" => contribution_weights["scope"].to_i,
1141
+ "pr_reviews" => contribution_weights["pr_reviews"].to_i
1142
+ }
1143
+ else
1144
+ weights = {
1145
+ "lines_of_code" => 0,
1146
+ "complexity" => 0,
1147
+ "technical_depth" => 0,
1148
+ "scope" => 0,
1149
+ "pr_reviews" => 0
1150
+ }
1151
+ end
1152
+
1153
+ user = {
1154
+ "username" => username,
1155
+ "commit_count" => changes,
1156
+ "pr_count" => pr_count,
1157
+ "review_count" => user_data["review_count"].to_i,
1158
+ "lines_changed" => user_data["lines_changed"].to_i,
1159
+ "avatar_url" => user_data["avatar_url"],
1160
+ "contribution_weights" => weights,
1161
+ "total_score" => user_data["total_score"].to_i
1162
+ }
1163
+
1164
+ normalized["active_users"] << user
1165
+ end
1166
+ end
1167
+ end
1168
+
1169
+ # Extract organizations data
1170
+ normalized["organizations"] = []
1171
+ data.each do |org_name, org_data|
1172
+ next unless org_data.is_a?(Hash)
1173
+
1174
+ # Create organization structure
1175
+ org = {
1176
+ "name" => org_name,
1177
+ "total_commits" => 0,
1178
+ "total_pull_requests" => 0,
1179
+ "total_reviews" => 0,
1180
+ "active_users_count" => 0,
1181
+ "repositories" => []
1182
+ }
1183
+
1184
+ # Extract metadata if available
1185
+ if org_data.key?("_meta") && org_data["_meta"].is_a?(Hash)
1186
+ org["total_commits"] = org_data["_meta"]["total_commits"].to_i
1187
+ org["total_pull_requests"] = org_data["_meta"]["total_pull_requests"].to_i
1188
+ org["total_reviews"] = org_data["_meta"]["total_reviews"].to_i
1189
+ end
1190
+
1191
+ # Count active users
1192
+ org["active_users_count"] = org_data.count { |k, v| k != "_meta" && v.is_a?(Hash) }
1193
+
1194
+ # Extract repositories if available
1195
+ if org_data.key?("_meta") && org_data["_meta"].is_a?(Hash) &&
1196
+ org_data["_meta"].key?("repo_stats") && org_data["_meta"]["repo_stats"].is_a?(Array)
1197
+
1198
+ org_data["_meta"]["repo_stats"].each do |repo|
1199
+ next unless repo.is_a?(Hash)
1200
+
1201
+ repo_data = {
1202
+ "name" => repo["name"] || "",
1203
+ "commit_count" => repo["total_commits"].to_i,
1204
+ "pr_count" => repo["open_prs"].to_i,
1205
+ "review_count" => 0,
1206
+ "url" => ""
1207
+ }
1208
+
1209
+ # Build GitHub URL if possible
1210
+ if repo["path"]
1211
+ repo_data["url"] = "https://github.com/#{repo["path"]}"
1212
+ end
1213
+
1214
+ org["repositories"] << repo_data
1215
+ end
1216
+ end
1217
+
1218
+ normalized["organizations"] << org
1219
+ end
1220
+ else
1221
+ # Data is already normalized or has a different structure
1222
+ normalized = data
1223
+
1224
+ # Ensure summary exists
1225
+ normalized["summary"] ||= {
1226
+ "active_users_count" => normalized["active_users"].is_a?(Array) ? normalized["active_users"].size : 0,
1227
+ "total_commits" => 0,
1228
+ "total_pull_requests" => 0
1229
+ }
1230
+
1231
+ # Ensure active_users exists
1232
+ normalized["active_users"] ||= []
1233
+
1234
+ # Ensure organizations exists
1235
+ normalized["organizations"] ||= []
1236
+ end
1237
+
1238
+ # Merge users across organizations
1239
+ merged_users = {}
1240
+ normalized["active_users"].each do |user|
1241
+ username = user["username"]
1242
+ unless merged_users[username]
1243
+ merged_users[username] = {
1244
+ username: username,
1245
+ lines_changed: 0,
1246
+ total_score: 0,
1247
+ contribution_weights: {
1248
+ "lines_of_code" => 0,
1249
+ "complexity" => 0,
1250
+ "technical_depth" => 0,
1251
+ "scope" => 0,
1252
+ "pr_reviews" => 0
1253
+ },
1254
+ organizations: [],
1255
+ org_details: {}
1256
+ }
1257
+ end
1258
+
1259
+ # Add this organization to the list
1260
+ merged_users[username][:org_details][user["organization"]] = {
1261
+ data: user,
1262
+ lines_changed: user["lines_changed"].to_i
1263
+ }
1264
+
1265
+ # Add organization to list if not present
1266
+ unless merged_users[username][:organizations].include?(user["organization"])
1267
+ merged_users[username][:organizations] << user["organization"]
1268
+ end
1269
+
1270
+ # Add lines changed
1271
+ merged_users[username][:lines_changed] += user["lines_changed"].to_i
1272
+
1273
+ # Use highest score
1274
+ user_score = user["total_score"].to_i
1275
+ if user_score > merged_users[username][:total_score]
1276
+ merged_users[username][:total_score] = user_score
1277
+ end
1278
+
1279
+ # Use highest contribution weights
1280
+ if user["contribution_weights"].is_a?(Hash)
1281
+ weights = user["contribution_weights"]
1282
+ ["lines_of_code", "complexity", "technical_depth", "scope", "pr_reviews"].each do |key|
1283
+ weight_value = weights[key].to_i rescue 0
1284
+ if weight_value > merged_users[username][:contribution_weights][key]
1285
+ merged_users[username][:contribution_weights][key] = weight_value
1286
+ end
1287
+ end
1288
+ end
1289
+ end
1290
+
1291
+ # Replace active_users with merged users
1292
+ normalized["active_users"] = merged_users.values.sort_by { |u| -1 * u[:total_score] }
1293
+
1294
+ normalized
1295
+ end
1296
+ end
1297
+ end