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.
- checksums.yaml +7 -0
- data/README.md +35 -0
- data/Rakefile +4 -0
- data/bin/console +11 -0
- data/bin/github-daily-digest +140 -0
- data/bin/setup +8 -0
- data/github-daily-digest.gemspec +47 -0
- data/github-daily-digest.rb +20 -0
- data/lib/activity_analyzer.rb +48 -0
- data/lib/configuration.rb +260 -0
- data/lib/daily_digest_runner.rb +932 -0
- data/lib/gemini_service.rb +616 -0
- data/lib/github-daily-digest/version.rb +5 -0
- data/lib/github_daily_digest.rb +16 -0
- data/lib/github_graphql_service.rb +1191 -0
- data/lib/github_service.rb +364 -0
- data/lib/html_formatter.rb +1297 -0
- data/lib/language_analyzer.rb +163 -0
- data/lib/markdown_formatter.rb +137 -0
- data/lib/output_formatter.rb +818 -0
- metadata +178 -0
@@ -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
|