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