github-pulse 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,438 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "date_helpers"
5
+
6
+ module Github
7
+ module Pulse
8
+ class Analyzer
9
+ using DateHelpers
10
+ attr_reader :repo_path, :github_repo, :token, :since, :until_date, :small_threshold, :medium_threshold
11
+
12
+ def initialize(repo_path:, github_repo: nil, token: nil, since: nil, until: nil, small_threshold: 50, medium_threshold: 250)
13
+ @repo_path = File.expand_path(repo_path)
14
+ @github_repo = github_repo
15
+ @token = token
16
+ @since = since
17
+ @until_date = binding.local_variable_get(:until)
18
+ @small_threshold = small_threshold
19
+ @medium_threshold = medium_threshold
20
+ end
21
+
22
+ def analyze
23
+ report = {
24
+ metadata: {
25
+ analyzed_at: Time.now.iso8601,
26
+ repository: nil,
27
+ period: {
28
+ since: since,
29
+ until: until_date
30
+ }
31
+ },
32
+ pull_requests: {},
33
+ commits: {},
34
+ lines_of_code: {},
35
+ commit_activity: {},
36
+ visualization_data: {}
37
+ }
38
+
39
+ # Try to analyze local git repository ONLY if no explicit GitHub repo is specified
40
+ # OR if the local repo matches the specified GitHub repo
41
+ should_analyze_local = false
42
+ local_github_repo = nil
43
+
44
+ if File.exist?(File.join(repo_path, ".git"))
45
+ git_analyzer = GitAnalyzer.new(repo_path)
46
+ local_github_repo = git_analyzer.remote_url
47
+
48
+ # Only analyze local git if:
49
+ # 1. No GitHub repo was specified (analyzing local only)
50
+ # 2. The specified GitHub repo matches the local repo's remote
51
+ if !github_repo || (github_repo == local_github_repo)
52
+ should_analyze_local = true
53
+
54
+ # Get commits by author
55
+ commits = git_analyzer.analyze_commits(since: since, until_date: until_date)
56
+ report[:commits] = format_commits_data(commits)
57
+
58
+ # Get lines of code by author
59
+ report[:lines_of_code] = git_analyzer.lines_of_code_by_author
60
+
61
+ # Get commit activity
62
+ report[:commit_activity] = git_analyzer.commit_activity_by_day
63
+
64
+ # Set GitHub repo from remote if not specified
65
+ @github_repo ||= local_github_repo
66
+ end
67
+ end
68
+
69
+ # If we have GitHub repo info, fetch additional data
70
+ if github_repo
71
+ # Try gh CLI first if no token provided
72
+ if !token
73
+ require_relative "gh_client"
74
+ gh_client = GhClient.new(repo: github_repo)
75
+
76
+ if gh_client.available?
77
+ puts "Using GitHub CLI (gh) for API access..."
78
+
79
+ # Get repository info
80
+ report[:metadata][:repository] = gh_client.repository_info
81
+
82
+ # Get pull requests
83
+ prs = gh_client.pull_requests(since: since, until_date: until_date)
84
+ report[:pull_requests] = format_pull_requests_data(prs)
85
+
86
+ # Get contributor statistics
87
+ contrib_stats = gh_client.contributors_stats
88
+ report[:contributor_stats] = format_contributor_stats(contrib_stats)
89
+
90
+ # Get commits from GitHub if not analyzing local
91
+ if !should_analyze_local
92
+ commits = gh_client.commits_data(since: since, until_date: until_date)
93
+ report[:commits] = format_commits_data(commits)
94
+ end
95
+
96
+ # Get commit activity from GitHub
97
+ if report[:commit_activity].empty?
98
+ activity = gh_client.commit_activity
99
+ report[:commit_activity] = format_commit_activity(activity)
100
+ end
101
+ else
102
+ puts "Warning: No GitHub token provided and gh CLI not available or not authenticated."
103
+ puts "To enable GitHub features, either:"
104
+ puts " 1. Set GITHUB_TOKEN environment variable"
105
+ puts " 2. Install and authenticate gh CLI: https://cli.github.com"
106
+ end
107
+ else
108
+ # Use token-based client
109
+ github_client = GithubClient.new(repo: github_repo, token: token)
110
+
111
+ # Get repository info
112
+ report[:metadata][:repository] = github_client.repository_info
113
+
114
+ # Get pull requests
115
+ prs = github_client.pull_requests(since: since, until_date: until_date)
116
+ report[:pull_requests] = format_pull_requests_data(prs)
117
+
118
+ # Get contributor statistics
119
+ contrib_stats = github_client.contributors_stats
120
+ report[:contributor_stats] = format_contributor_stats(contrib_stats)
121
+
122
+ # Get commits from GitHub if not analyzing local
123
+ if !should_analyze_local
124
+ # For token-based client, we'd need to implement a commits method
125
+ # For now, contributor stats provides some commit data
126
+ end
127
+
128
+ # Get commit activity from GitHub
129
+ if report[:commit_activity].empty?
130
+ activity = github_client.commit_activity
131
+ report[:commit_activity] = format_commit_activity(activity)
132
+ end
133
+ end
134
+ end
135
+
136
+ # Generate visualization data
137
+ report[:visualization_data] = generate_visualization_data(report)
138
+
139
+ report
140
+ end
141
+
142
+ private
143
+
144
+ def format_commits_data(commits_by_author)
145
+ formatted = {}
146
+
147
+ commits_by_author.each do |author, commits|
148
+ formatted[author] = {
149
+ total_commits: commits.size,
150
+ total_additions: commits.sum { |c| c[:additions] },
151
+ total_deletions: commits.sum { |c| c[:deletions] },
152
+ commits: commits.map do |c|
153
+ {
154
+ sha: c[:sha][0..7],
155
+ message: c[:message],
156
+ time: c[:time].iso8601,
157
+ additions: c[:additions],
158
+ deletions: c[:deletions]
159
+ }
160
+ end
161
+ }
162
+ end
163
+
164
+ formatted
165
+ end
166
+
167
+ def format_pull_requests_data(prs)
168
+ by_author = {}
169
+
170
+ prs.each do |pr|
171
+ author = pr[:author]
172
+ by_author[author] ||= {
173
+ total_prs: 0,
174
+ merged: 0,
175
+ open: 0,
176
+ closed: 0,
177
+ total_additions: 0,
178
+ total_deletions: 0,
179
+ pull_requests: []
180
+ }
181
+
182
+ by_author[author][:total_prs] += 1
183
+ by_author[author][:merged] += 1 if pr[:merged_at]
184
+ by_author[author][:open] += 1 if pr[:state] == "open"
185
+ by_author[author][:closed] += 1 if pr[:state] == "closed" && !pr[:merged_at]
186
+ by_author[author][:total_additions] += pr[:additions]
187
+ by_author[author][:total_deletions] += pr[:deletions]
188
+
189
+ by_author[author][:pull_requests] << {
190
+ number: pr[:number],
191
+ title: pr[:title],
192
+ created_at: pr[:created_at].iso8601,
193
+ merged_at: pr[:merged_at]&.iso8601,
194
+ closed_at: pr[:closed_at]&.iso8601,
195
+ state: pr[:state],
196
+ merged: !pr[:merged_at].nil?,
197
+ additions: pr[:additions],
198
+ deletions: pr[:deletions],
199
+ changed_files: pr[:changed_files]
200
+ }
201
+ end
202
+
203
+ by_author
204
+ end
205
+
206
+ def format_contributor_stats(stats)
207
+ return {} unless stats
208
+
209
+ formatted = {}
210
+
211
+ stats.each do |contributor|
212
+ author = contributor[:author]
213
+ formatted[author] = {
214
+ total_commits: contributor[:total_commits],
215
+ weekly_activity: contributor[:weeks].select { |w| w[:commits] > 0 }.map do |week|
216
+ {
217
+ week: week[:week_start].iso8601,
218
+ commits: week[:commits],
219
+ additions: week[:additions],
220
+ deletions: week[:deletions]
221
+ }
222
+ end
223
+ }
224
+ end
225
+
226
+ formatted
227
+ end
228
+
229
+ def format_commit_activity(activity)
230
+ return {} unless activity
231
+
232
+ activity_hash = {}
233
+
234
+ activity.each do |week|
235
+ date = week[:week_start]
236
+ activity_hash[date.iso8601] = {
237
+ total: week[:total],
238
+ days: week[:days]
239
+ }
240
+ end
241
+
242
+ activity_hash
243
+ end
244
+
245
+ def generate_visualization_data(report)
246
+ viz_data = {}
247
+
248
+ # Pull requests over time (for stacked bar chart)
249
+ if report[:pull_requests].any?
250
+ pr_timeline = Hash.new { |h, k| h[k] = Hash.new(0) }
251
+
252
+ report[:pull_requests].each do |author, data|
253
+ data[:pull_requests].each do |pr|
254
+ date = Date.parse(pr[:created_at]).beginning_of_month.iso8601
255
+ pr_timeline[date][author] += 1
256
+ end
257
+ end
258
+
259
+ viz_data[:pull_requests_timeline] = pr_timeline.sort.map do |date, authors|
260
+ { date: date, authors: authors }
261
+ end
262
+ end
263
+
264
+ # Lines of code by author (for bar chart)
265
+ if report[:lines_of_code].any?
266
+ viz_data[:lines_of_code_chart] = report[:lines_of_code].map do |author, lines|
267
+ { author: author, lines: lines }
268
+ end.sort_by { |d| -d[:lines] }
269
+ end
270
+
271
+ # Commit activity over time (for line chart)
272
+ if report[:commit_activity].any?
273
+ viz_data[:commit_activity_chart] = report[:commit_activity].map do |date, count|
274
+ { date: date.to_s, commits: count }
275
+ end
276
+ end
277
+
278
+ # Commits by author over time (for stacked area chart)
279
+ if report[:commits].any?
280
+ commit_timeline = Hash.new { |h, k| h[k] = Hash.new(0) }
281
+
282
+ report[:commits].each do |author, data|
283
+ data[:commits].each do |commit|
284
+ date = Date.parse(commit[:time]).beginning_of_week.iso8601
285
+ commit_timeline[date][author] += 1
286
+ end
287
+ end
288
+
289
+ viz_data[:commits_timeline] = commit_timeline.sort.map do |date, authors|
290
+ { date: date, authors: authors }
291
+ end
292
+ end
293
+
294
+ # Lines changed over time by author (additions + deletions)
295
+ if report[:contributor_stats] && report[:contributor_stats].any?
296
+ lines_timeline = Hash.new { |h, k| h[k] = Hash.new { |h2, k2| h2[k2] = { additions: 0, deletions: 0 } } }
297
+
298
+ report[:contributor_stats].each do |author, data|
299
+ data[:weekly_activity].each do |week|
300
+ date = week[:week]
301
+ lines_timeline[date][author][:additions] += week[:additions]
302
+ lines_timeline[date][author][:deletions] += week[:deletions]
303
+ end
304
+ end
305
+
306
+ viz_data[:lines_changed_timeline] = lines_timeline.sort.map do |date, authors|
307
+ { date: date, authors: authors }
308
+ end
309
+ end
310
+
311
+ # PR cycle time over time (p50/p90/max days by week)
312
+ if report[:pull_requests].any?
313
+ by_week = Hash.new { |h, k| h[k] = [] }
314
+ report[:pull_requests].each do |_author, data|
315
+ data[:pull_requests].each do |pr|
316
+ next unless pr[:merged]
317
+ begin
318
+ created = Date.parse(pr[:created_at])
319
+ # merged_at not stored in nested pr, but we track merged flag only; fall back to closed date when merged
320
+ # We didn't include timestamps other than created_at; skip if unavailable
321
+ rescue
322
+ next
323
+ end
324
+ end
325
+ end
326
+ # We can compute cycle time only if detailed merged_at is available in PRs
327
+ # The GitHub clients provide merged_at at top-level before formatting.
328
+ # To preserve it, check if we stored it in pull_requests data; if not, skip this section.
329
+ # Detect presence
330
+ has_merged_at = report[:pull_requests].values.any? do |d|
331
+ d[:pull_requests].any? { |pr| pr.key?(:merged_at) }
332
+ end rescue false
333
+
334
+ if has_merged_at
335
+ by_week = Hash.new { |h, k| h[k] = [] }
336
+ report[:pull_requests].each do |_author, data|
337
+ data[:pull_requests].each do |pr|
338
+ next unless pr[:merged]
339
+ begin
340
+ created = Time.parse(pr[:created_at])
341
+ merged_at = Time.parse(pr[:merged_at])
342
+ days = ((merged_at - created) / 86400.0)
343
+ week = Date.parse(pr[:created_at]).beginning_of_week.iso8601
344
+ by_week[week] << days
345
+ rescue
346
+ next
347
+ end
348
+ end
349
+ end
350
+
351
+ viz_data[:pr_cycle_time_timeline] = by_week.sort.map do |week, values|
352
+ sorted = values.sort
353
+ p50 = percentile(sorted, 0.5)
354
+ p90 = percentile(sorted, 0.9)
355
+ { week: week, p50: p50.round(2), p90: p90.round(2), max: sorted.last.round(2), count: values.size }
356
+ end
357
+ end
358
+ end
359
+
360
+ # PR size mix over time (small/medium/large by week)
361
+ if report[:pull_requests].any?
362
+ buckets_by_week = Hash.new { |h, k| h[k] = { small: 0, medium: 0, large: 0 } }
363
+ report[:pull_requests].each do |_author, data|
364
+ data[:pull_requests].each do |pr|
365
+ begin
366
+ additions = pr[:additions] || 0
367
+ deletions = pr[:deletions] || 0
368
+ size = additions + deletions
369
+ week = Date.parse(pr[:created_at]).beginning_of_week.iso8601
370
+ if size <= small_threshold
371
+ buckets_by_week[week][:small] += 1
372
+ elsif size <= medium_threshold
373
+ buckets_by_week[week][:medium] += 1
374
+ else
375
+ buckets_by_week[week][:large] += 1
376
+ end
377
+ rescue
378
+ next
379
+ end
380
+ end
381
+ end
382
+ viz_data[:pr_size_mix_timeline] = buckets_by_week.sort.map { |week, counts| { week: week, **counts } }
383
+ end
384
+
385
+ # Commit activity heatmap (weekday x hour)
386
+ if report[:commits].any?
387
+ heatmap = Array.new(7) { Array.new(24, 0) }
388
+ report[:commits].each do |_author, data|
389
+ data[:commits].each do |commit|
390
+ begin
391
+ t = Time.parse(commit[:time])
392
+ wday = t.wday # 0..6 (Sun..Sat)
393
+ hour = t.hour
394
+ heatmap[wday][hour] += 1
395
+ rescue
396
+ next
397
+ end
398
+ end
399
+ end
400
+ viz_data[:commit_activity_heatmap] = heatmap
401
+ end
402
+
403
+ # Open PRs aging buckets
404
+ if report[:pull_requests].any?
405
+ buckets = { "0-3d" => 0, "4-7d" => 0, "8-14d" => 0, "15+d" => 0 }
406
+ report[:pull_requests].each do |_author, data|
407
+ data[:pull_requests].each do |pr|
408
+ next unless pr[:state] == "open"
409
+ begin
410
+ age_days = ((Time.now - Time.parse(pr[:created_at])) / 86400.0)
411
+ key = case age_days
412
+ when 0..3 then "0-3d"
413
+ when 3...7 then "4-7d"
414
+ when 7...15 then "8-14d"
415
+ else "15+d"
416
+ end
417
+ buckets[key] += 1
418
+ rescue
419
+ next
420
+ end
421
+ end
422
+ end
423
+ viz_data[:open_prs_aging] = buckets
424
+ end
425
+
426
+ viz_data
427
+ end
428
+
429
+ def percentile(sorted_values, p)
430
+ return 0 if sorted_values.empty?
431
+ rank = (p * (sorted_values.length - 1))
432
+ lower = sorted_values[rank.floor]
433
+ upper = sorted_values[rank.ceil]
434
+ lower + (upper - lower) * (rank - rank.floor)
435
+ end
436
+ end
437
+ end
438
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "json"
5
+
6
+ module Github
7
+ module Pulse
8
+ class CLI < Thor
9
+ desc "analyze [REPO_PATH]", "Analyze GitHub repository activity"
10
+ option :repo, type: :string, desc: "GitHub repository (owner/repo format)"
11
+ option :token, type: :string, desc: "GitHub token (or set GITHUB_TOKEN env, or use gh CLI)"
12
+ option :output, type: :string, default: "github-pulse-report.json", desc: "Output file path"
13
+ option :format, type: :string, default: "json", enum: ["json", "pretty", "html"], desc: "Output format"
14
+ option :since, type: :string, desc: "Analyze activity since this date (YYYY-MM-DD)"
15
+ option :until, type: :string, desc: "Analyze activity until this date (YYYY-MM-DD)"
16
+ option :small_threshold, type: :numeric, default: 50, desc: "PR size threshold for 'small' (additions+deletions)"
17
+ option :medium_threshold, type: :numeric, default: 250, desc: "PR size threshold for 'medium' (additions+deletions)"
18
+
19
+ def analyze(repo_path = ".")
20
+ token = options[:token] || ENV["GITHUB_TOKEN"]
21
+
22
+ analyzer = Analyzer.new(
23
+ repo_path: repo_path,
24
+ github_repo: options[:repo],
25
+ token: token,
26
+ since: options[:since],
27
+ until: options[:until],
28
+ small_threshold: options[:small_threshold],
29
+ medium_threshold: options[:medium_threshold]
30
+ )
31
+
32
+ puts "Analyzing repository activity..."
33
+ report = analyzer.analyze
34
+
35
+ output_file = options[:output]
36
+
37
+ if options[:format] == "html"
38
+ require_relative "html_reporter"
39
+ reporter = HtmlReporter.new(report)
40
+ output = reporter.generate
41
+ output_file = output_file.sub(/\.json$/, '.html') unless output_file.end_with?('.html')
42
+ else
43
+ reporter = Reporter.new(report)
44
+ output = reporter.generate(format: options[:format].to_sym)
45
+ end
46
+
47
+ File.write(output_file, output)
48
+ puts "Report saved to #{output_file}"
49
+
50
+ if options[:format] == "pretty"
51
+ puts "\n" + output
52
+ elsif options[:format] == "html"
53
+ puts "Open #{output_file} in your browser to view the interactive report"
54
+
55
+ # Try to open in browser automatically
56
+ case RUBY_PLATFORM
57
+ when /darwin/
58
+ system("open", output_file)
59
+ when /linux/
60
+ system("xdg-open", output_file)
61
+ when /win32|mingw/
62
+ system("start", output_file)
63
+ end
64
+ end
65
+ rescue StandardError => e
66
+ say "Error: #{e.message}", :red
67
+ exit 1
68
+ end
69
+
70
+ desc "version", "Display version"
71
+ def version
72
+ puts "github-pulse #{Github::Pulse::VERSION}"
73
+ end
74
+
75
+ default_task :analyze
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Github
6
+ module Pulse
7
+ module DateHelpers
8
+ refine Date do
9
+ def beginning_of_week
10
+ self - (self.cwday - 1)
11
+ end
12
+
13
+ def beginning_of_month
14
+ Date.new(year, month, 1)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end