github-daily-digest 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,818 @@
1
+ module GithubDailyDigest
2
+ class OutputFormatter
3
+ def initialize(config:, logger:)
4
+ @config = config
5
+ @logger = logger
6
+ end
7
+
8
+ def format(results, format_type = nil)
9
+ # If a specific format_type is provided, use that
10
+ # Otherwise, use the first format from the config
11
+ output_format = format_type || (@config.output_formats&.first || 'json')
12
+
13
+ case output_format
14
+ when 'json'
15
+ format_as_json(results)
16
+ when 'markdown'
17
+ format_as_markdown(results)
18
+ when 'html'
19
+ # HTML formatting is handled by HtmlFormatter but we need to recognize it as valid
20
+ format_as_json(results) # Return JSON data that HtmlFormatter will use
21
+ else
22
+ @logger.warn("Unknown output format: #{output_format}, defaulting to JSON")
23
+ format_as_json(results)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def format_as_json(results)
30
+ JSON.pretty_generate(results)
31
+ end
32
+
33
+ def format_as_markdown(results)
34
+ # Create a very simplistic markdown output for robustness
35
+ markdown = "# GitHub Activity Digest\n\n"
36
+
37
+ # Extract generation time or use current time
38
+ generated_at = if results.is_a?(Hash) && results[:_meta].is_a?(Hash) && results[:_meta][:generated_at].is_a?(String)
39
+ results[:_meta][:generated_at]
40
+ else
41
+ Time.now.strftime("%Y-%m-%d %H:%M:%S")
42
+ end
43
+
44
+ markdown << "Generated on: #{generated_at}\n\n"
45
+
46
+ # Add summary statistics section if available
47
+ if results.is_a?(Hash) && (results["summary_statistics"].is_a?(Hash) || results[:summary_statistics].is_a?(Hash))
48
+ stats = results["summary_statistics"] || results[:summary_statistics]
49
+
50
+ markdown << "## Summary Statistics\n\n"
51
+
52
+ # Add AI summary if available
53
+ if stats["ai_summary"].is_a?(String)
54
+ markdown << "> #{stats["ai_summary"]}\n\n"
55
+ end
56
+
57
+ # Create a summary statistics table
58
+ markdown << "| Metric | Value |\n"
59
+ markdown << "| --- | --- |\n"
60
+ markdown << "| **Time Period** | #{stats["period"] || "Last 7 days"} |\n"
61
+ markdown << "| **Total Commits** | #{stats["total_commits"] || 0} |\n"
62
+ markdown << "| **Total Lines Changed** | #{stats["total_lines_changed"] || 0} |\n"
63
+ markdown << "| **Active Developers** | #{stats["active_users_count"] || 0} |\n"
64
+ markdown << "| **Active Repositories** | #{stats["active_repos_count"] || 0} |\n"
65
+ end
66
+
67
+ # Add active users section
68
+ markdown << "\n## Active Users\n\n"
69
+
70
+ # Collect all active users from all organizations and merge by username
71
+ merged_users = {}
72
+
73
+ if results.is_a?(Hash)
74
+ results.each do |org_name, org_data|
75
+ next if org_name == :_meta || org_name == "_meta" || org_name == "summary_statistics" || !org_data.is_a?(Hash)
76
+
77
+ # Get users who have activity
78
+ org_data.each do |username, user_data|
79
+ next if username == "_meta" || username == :_meta || !user_data.is_a?(Hash)
80
+
81
+ # Check if user has any meaningful activity
82
+ has_activity = false
83
+
84
+ # Check if user has commits
85
+ if user_data["commits"].is_a?(Array) && !user_data["commits"].empty?
86
+ has_activity = true
87
+ end
88
+
89
+ # Check for various activity indicators
90
+ has_activity ||= user_data["commits_count"].to_i > 0 if user_data["commits_count"]
91
+ has_activity ||= user_data["commit_count"].to_i > 0 if user_data["commit_count"]
92
+ has_activity ||= user_data["prs_count"].to_i > 0 if user_data["prs_count"]
93
+ has_activity ||= user_data["pr_count"].to_i > 0 if user_data["pr_count"]
94
+ has_activity ||= user_data["lines_changed"].to_i > 0 if user_data["lines_changed"]
95
+
96
+ if has_activity
97
+ # Initialize merged user if doesn't exist
98
+ unless merged_users[username]
99
+ merged_users[username] = {
100
+ username: username,
101
+ lines_changed: 0,
102
+ total_score: 0,
103
+ contribution_weights: {
104
+ "lines_of_code" => 0,
105
+ "complexity" => 0,
106
+ "technical_depth" => 0,
107
+ "scope" => 0,
108
+ "pr_reviews" => 0
109
+ },
110
+ organizations: [],
111
+ org_details: {}
112
+ }
113
+ end
114
+
115
+ # Add this organization to the list
116
+ org_details = merged_users[username][:org_details][org_name] = {
117
+ data: user_data,
118
+ lines_changed: user_data["lines_changed"].to_i
119
+ }
120
+
121
+ # Add organization to list if not present
122
+ unless merged_users[username][:organizations].include?(org_name)
123
+ merged_users[username][:organizations] << org_name
124
+ end
125
+
126
+ # Add lines changed
127
+ merged_users[username][:lines_changed] += user_data["lines_changed"].to_i
128
+
129
+ # Use highest score
130
+ user_score = user_data["total_score"].to_i
131
+ if user_score > merged_users[username][:total_score]
132
+ merged_users[username][:total_score] = user_score
133
+ end
134
+
135
+ # Use highest contribution weights
136
+ if user_data["contribution_weights"].is_a?(Hash)
137
+ weights = user_data["contribution_weights"]
138
+ ["lines_of_code", "complexity", "technical_depth", "scope", "pr_reviews"].each do |key|
139
+ weight_value = weights[key].to_i rescue 0
140
+ if weight_value > merged_users[username][:contribution_weights][key]
141
+ merged_users[username][:contribution_weights][key] = weight_value
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+
150
+ active_users = merged_users.values
151
+
152
+ if active_users.empty?
153
+ markdown << "No active users found in the specified time period.\n\n"
154
+ else
155
+ # Create a table of active users with scores
156
+ markdown << "| Username | Organizations | Lines Changed | Contribution Score | Code | Complexity | Tech Depth | Scope | Reviews |\n"
157
+ markdown << "| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n"
158
+
159
+ active_users.sort_by { |u| -1 * u[:total_score] }.each do |user|
160
+ username = user[:username]
161
+ orgs = user[:organizations].join(", ")
162
+ lines_changed = user[:lines_changed]
163
+ score = user[:total_score]
164
+
165
+ # Extract contribution weights
166
+ weights = user[:contribution_weights]
167
+ code_weight = weights["lines_of_code"].to_i
168
+ complexity_weight = weights["complexity"].to_i
169
+ tech_depth_weight = weights["technical_depth"].to_i
170
+ scope_weight = weights["scope"].to_i
171
+ reviews_weight = weights["pr_reviews"].to_i
172
+
173
+ markdown << "| #{username} | #{orgs} | #{lines_changed} | #{score} | #{code_weight} | #{complexity_weight} | #{tech_depth_weight} | #{scope_weight} | #{reviews_weight} |\n"
174
+ end
175
+
176
+ markdown << "\n"
177
+
178
+ # Add detailed breakdown for each user
179
+ markdown << "## User Activity Details\n\n"
180
+
181
+ active_users.sort_by { |u| -1 * u[:total_score] }.each do |user|
182
+ username = user[:username]
183
+ orgs = user[:organizations].join(", ")
184
+
185
+ markdown << "### #{username} (#{orgs})\n\n"
186
+
187
+ # Collect summaries from all organizations
188
+ summaries = []
189
+ repositories = []
190
+
191
+ user[:organizations].each do |org_name|
192
+ org_data = user[:org_details][org_name]
193
+ next unless org_data
194
+
195
+ # Add user summary if available
196
+ if org_data[:data]["summary"].is_a?(String) && !org_data[:data]["summary"].empty?
197
+ summaries << org_data[:data]["summary"]
198
+ end
199
+
200
+ # Collect repositories
201
+ if org_data[:data]["projects"].is_a?(Array)
202
+ org_data[:data]["projects"].each do |project|
203
+ if project.is_a?(Hash)
204
+ repo_name = project["name"] || project[:name] || "Unknown repository"
205
+ commits = project["commits"] || project[:commits] || 0
206
+ changes = project["lines_changed"] || project[:lines_changed] || 0
207
+
208
+ repositories << {
209
+ name: repo_name,
210
+ org: org_name,
211
+ commits: commits,
212
+ changes: changes
213
+ }
214
+ else
215
+ # Handle case where project is just a string with repo name
216
+ repo_name = project.to_s
217
+ repositories << {
218
+ name: repo_name,
219
+ org: org_name,
220
+ commits: 0,
221
+ changes: 0
222
+ }
223
+ end
224
+ end
225
+ end
226
+ end
227
+
228
+ # Output the summary if available
229
+ if summaries.any?
230
+ # Find the longest summary
231
+ best_summary = summaries.max_by(&:length)
232
+ markdown << "> #{best_summary}\n\n"
233
+ end
234
+
235
+ # Add repositories
236
+ if repositories.any?
237
+ markdown << "**Repositories:**\n\n"
238
+ repositories.each do |repo|
239
+ if repo[:commits] > 0 || repo[:changes] > 0
240
+ markdown << "- #{repo[:name]} (#{repo[:org]}): #{repo[:commits]} commits, #{repo[:changes]} lines changed\n"
241
+ else
242
+ markdown << "- #{repo[:name]} (#{repo[:org]})\n"
243
+ end
244
+ end
245
+ markdown << "\n"
246
+ end
247
+
248
+ # Add languages if available (combining from all orgs)
249
+ all_languages = {}
250
+ user[:organizations].each do |org_name|
251
+ org_data = user[:org_details][org_name]
252
+ next unless org_data
253
+
254
+ if org_data[:data][:language_distribution].is_a?(Hash) && !org_data[:data][:language_distribution].empty?
255
+ org_data[:data][:language_distribution].each do |lang, percentage|
256
+ all_languages[lang] ||= 0
257
+ all_languages[lang] += percentage.to_f
258
+ end
259
+ end
260
+ end
261
+
262
+ if all_languages.any?
263
+ # Normalize percentages
264
+ total = all_languages.values.sum
265
+ all_languages.each { |lang, value| all_languages[lang] = (value / total * 100) }
266
+
267
+ # Use the new function to generate visual language distribution
268
+ markdown << generate_language_distribution_markdown(all_languages)
269
+ end
270
+
271
+ # Add recent commit messages combining from all orgs
272
+ all_commits = []
273
+ user[:organizations].each do |org_name|
274
+ org_data = user[:org_details][org_name]
275
+ next unless org_data
276
+
277
+ if org_data[:data]["recent_commits"].is_a?(Array) && !org_data[:data]["recent_commits"].empty?
278
+ org_data[:data]["recent_commits"].each do |commit|
279
+ message = commit["message"] || commit[:message]
280
+ repo = commit["repository"] || commit[:repository]
281
+ all_commits << {
282
+ message: message,
283
+ repo: repo,
284
+ org: org_name
285
+ }
286
+ end
287
+ elsif org_data[:data]["commits"].is_a?(Array) && !org_data[:data]["commits"].empty?
288
+ org_data[:data]["commits"].each do |commit|
289
+ message = commit["message"] || commit[:message] || "No message"
290
+ repo = commit["repository"] || commit[:repository] || "Unknown repository"
291
+ all_commits << {
292
+ message: message,
293
+ repo: repo,
294
+ org: org_name
295
+ }
296
+ end
297
+ end
298
+ end
299
+
300
+ if all_commits.any?
301
+ markdown << "**Recent Work:**\n\n"
302
+ all_commits.take(5).each do |commit|
303
+ markdown << "- #{commit[:repo]} (#{commit[:org]}): #{commit[:message]}\n"
304
+ end
305
+ markdown << "\n"
306
+ end
307
+ end
308
+ end
309
+
310
+ return markdown
311
+ end
312
+
313
+ # Generate a visual representation of language distribution for markdown
314
+ def generate_language_distribution_markdown(languages)
315
+ return "" if languages.nil? || !languages.is_a?(Hash) || languages.empty?
316
+
317
+ markdown = "**Languages Used:**\n\n"
318
+
319
+ # Sort languages by percentage (highest first)
320
+ sorted_languages = languages.sort_by { |_, v| -v }
321
+
322
+ # Calculate the max bar width (in characters)
323
+ max_bar_width = 30
324
+
325
+ sorted_languages.each do |lang, percentage|
326
+ # Calculate bar width based on percentage
327
+ bar_width = (percentage * max_bar_width / 100.0).round
328
+ bar = "█" * bar_width
329
+
330
+ # Format the percentage to one decimal place
331
+ formatted_percentage = percentage.round(1)
332
+
333
+ # Add the language bar
334
+ markdown << "- **#{lang}**: #{formatted_percentage}% #{bar}\n"
335
+ end
336
+
337
+ markdown << "\n"
338
+ return markdown
339
+ end
340
+
341
+ def self.generate_active_users_table(user_activity, org_activity)
342
+ return "No active users found in this time period." if user_activity.nil? || user_activity.empty?
343
+
344
+ # Calculate total contribution score for each user based on weights
345
+ user_activity.each do |user|
346
+ analysis = user[:gemini_analysis] || {}
347
+
348
+ # Get weights from either string or symbol keys
349
+ weights = nil
350
+ if analysis["contribution_weights"].is_a?(Hash)
351
+ weights = analysis["contribution_weights"]
352
+ elsif analysis[:contribution_weights].is_a?(Hash)
353
+ weights = analysis[:contribution_weights]
354
+ else
355
+ weights = {
356
+ "lines_of_code" => 0,
357
+ "complexity" => 0,
358
+ "technical_depth" => 0,
359
+ "scope" => 0,
360
+ "pr_reviews" => 0
361
+ }
362
+ end
363
+
364
+ # Calculate total score as sum of all weights
365
+ total_score = 0
366
+ if weights
367
+ if weights.is_a?(Hash)
368
+ total_score += weights["lines_of_code"].to_i rescue 0
369
+ total_score += weights["complexity"].to_i rescue 0
370
+ total_score += weights["technical_depth"].to_i rescue 0
371
+ total_score += weights["scope"].to_i rescue 0
372
+ total_score += weights["pr_reviews"].to_i rescue 0
373
+ end
374
+ end
375
+
376
+ # Use the total_score from analysis if it exists, otherwise use calculated score
377
+ if analysis["total_score"].to_i > 0
378
+ total_score = analysis["total_score"].to_i
379
+ elsif analysis[:total_score].to_i > 0
380
+ total_score = analysis[:total_score].to_i
381
+ end
382
+
383
+ # Store the total score for sorting
384
+ user[:total_contribution_score] = total_score
385
+ end
386
+
387
+ # Sort users by total contribution score (highest to lowest)
388
+ sorted_users = user_activity.sort_by { |user| -user[:total_contribution_score].to_i }
389
+
390
+ user_rows = []
391
+
392
+ # Create rows for each user
393
+ sorted_users.each do |user|
394
+ # Use the gemini analysis if available, otherwise use fallback values
395
+ analysis = user[:gemini_analysis] || {}
396
+ commits = user[:commit_count] || 0
397
+
398
+ # Get projects from either string or symbol key
399
+ projects = analysis["projects"] || analysis[:projects] || []
400
+ # Display projects as a comma-separated list, or "N/A" if none
401
+ project_list = projects.empty? ? "N/A" : (projects.is_a?(Array) ? projects.join(", ") : projects.to_s)
402
+
403
+ # Get weights from either string or symbol keys
404
+ weights = analysis["contribution_weights"] || analysis[:contribution_weights] || {}
405
+
406
+ # Format the weights as a visual indicator
407
+ loc_weight = weights["lines_of_code"].to_i rescue 0
408
+ complexity_weight = weights["complexity"].to_i rescue 0
409
+ depth_weight = weights["technical_depth"].to_i rescue 0
410
+ scope_weight = weights["scope"].to_i rescue 0
411
+ pr_weight = weights["pr_reviews"].to_i rescue 0
412
+
413
+ total_score = user[:total_contribution_score].to_i
414
+
415
+ # Format contribution score with visual indicator
416
+ score_display = case total_score
417
+ when 30..50
418
+ "🔥 #{total_score}" # High contribution
419
+ when 15..29
420
+ "👍 #{total_score}" # Medium contribution
421
+ else
422
+ "#{total_score}" # Low contribution
423
+ end
424
+
425
+ # Format the weights table
426
+ weights_display = "LOC: #{loc_weight} | Complexity: #{complexity_weight} | Depth: #{depth_weight} | Scope: #{scope_weight} | PR: #{pr_weight}"
427
+
428
+ # Get other fields from either string or symbol keys
429
+ pr_count = analysis["pr_count"] || analysis[:pr_count] || 0
430
+ lines_changed = analysis["lines_changed"] || analysis[:lines_changed] || 0
431
+ summary = analysis["summary"] || analysis[:summary] || "N/A"
432
+
433
+ # Create row with user info and stats
434
+ user_rows << "| #{user[:username]} | #{commits} | #{pr_count} | #{lines_changed} | #{score_display} | #{weights_display} | #{summary} | #{project_list} |"
435
+ end
436
+
437
+ # Create the table with headers and all rows
438
+ <<~MARKDOWN
439
+ ## Active Users
440
+
441
+ Users are sorted by their total contribution score, which is calculated as the sum of individual contribution weights.
442
+ Each contribution weight is on a scale of 0-10 and considers different aspects of contribution value.
443
+
444
+ | User | Commits | PRs | Lines Changed | Total Score | Contribution Weights | Summary | Projects |
445
+ |------|---------|-----|---------------|-------------|----------------------|---------|----------|
446
+ #{user_rows.join("\n")}
447
+
448
+ MARKDOWN
449
+ end
450
+
451
+ def self.generate_combined_user_table(combined_users, users_with_gemini_activity, inactive_users, commits_by_user)
452
+ return "No active users found in this time period." if combined_users.nil? || combined_users.empty?
453
+
454
+ # Calculate total contribution score for each user based on weights
455
+ combined_users.each do |username, user_data|
456
+ next if username == :_meta
457
+
458
+ # Get weights from various potential sources
459
+ weights = nil
460
+ if users_with_gemini_activity[username] && users_with_gemini_activity[username][:contribution_weights]
461
+ weights = users_with_gemini_activity[username][:contribution_weights]
462
+ elsif user_data[:contribution_weights]
463
+ weights = user_data[:contribution_weights]
464
+ else
465
+ # Create default weights based on activity
466
+ commits = user_data[:commits].to_i || 0
467
+ pr_reviews = user_data[:pr_reviews].to_i || 0
468
+ lines_changed = user_data[:lines_changed].to_i || 0
469
+ project_count = user_data[:projects].size || 0
470
+
471
+ # Create appropriate weights based on activity metrics
472
+ loc_weight = if lines_changed > 20000
473
+ 8
474
+ elsif lines_changed > 10000
475
+ 6
476
+ elsif lines_changed > 5000
477
+ 4
478
+ elsif lines_changed > 1000
479
+ 2
480
+ else
481
+ 1
482
+ end
483
+
484
+ scope_weight = if project_count > 4
485
+ 8
486
+ elsif project_count > 2
487
+ 5
488
+ elsif project_count > 0
489
+ 3
490
+ else
491
+ 1
492
+ end
493
+
494
+ commit_weight = if commits > 50
495
+ 8
496
+ elsif commits > 20
497
+ 6
498
+ elsif commits > 10
499
+ 4
500
+ else
501
+ 1
502
+ end
503
+
504
+ pr_weight = if pr_reviews > 20
505
+ 8
506
+ elsif pr_reviews > 10
507
+ 6
508
+ elsif pr_reviews > 5
509
+ 4
510
+ else
511
+ 1
512
+ end
513
+
514
+ # Provide reasonable default weights
515
+ weights = {
516
+ "lines_of_code" => loc_weight,
517
+ "complexity" => [commit_weight, 3].min,
518
+ "technical_depth" => 3,
519
+ "scope" => scope_weight,
520
+ "pr_reviews" => pr_weight
521
+ }
522
+ end
523
+
524
+ # Calculate total score as sum of weights
525
+ total_score = 0
526
+ if weights
527
+ if weights.is_a?(Hash)
528
+ total_score += weights["lines_of_code"].to_i rescue 0
529
+ total_score += weights["complexity"].to_i rescue 0
530
+ total_score += weights["technical_depth"].to_i rescue 0
531
+ total_score += weights["scope"].to_i rescue 0
532
+ total_score += weights["pr_reviews"].to_i rescue 0
533
+ end
534
+ end
535
+
536
+ # Store the weights and total score
537
+ user_data[:contribution_weights] = weights
538
+ user_data[:total_contribution_score] = total_score
539
+ end
540
+
541
+ # Sort users by contribution score (highest to lowest)
542
+ sorted_users = combined_users.keys.reject { |username| username == :_meta }.sort_by do |username|
543
+ # Return a tuple for sorting: active users first, then by total score
544
+ # Negative values ensure descending order
545
+ user_data = combined_users[username]
546
+ has_activity = users_with_gemini_activity.key?(username) || commits_by_user.key?(username)
547
+ total_score = user_data[:total_contribution_score] || 0
548
+
549
+ [-1 * (has_activity ? 1 : 0), -1 * total_score]
550
+ end
551
+
552
+ user_rows = []
553
+
554
+ # Create rows for each user
555
+ sorted_users.each do |username|
556
+ user_data = combined_users[username]
557
+
558
+ # Format project names in a more readable way
559
+ projects = user_data[:projects].to_a if user_data[:projects]
560
+ project_names = if projects&.any?
561
+ projects.map { |p| p.split('/').last }.join(', ')
562
+ else
563
+ "-"
564
+ end
565
+
566
+ # Format the output fields with defaults
567
+ commits = user_data[:commits] || 0
568
+ pr_reviews = user_data[:pr_reviews] || 0
569
+ lines_changed = user_data[:lines_changed] || 0
570
+ total_score = user_data[:total_contribution_score] || 0
571
+
572
+ # Format contribution score with visual indicator
573
+ score_display = case total_score
574
+ when 30..50
575
+ "🔥 #{total_score}" # High contribution
576
+ when 15..29
577
+ "👍 #{total_score}" # Medium contribution
578
+ else
579
+ "#{total_score}" # Low contribution
580
+ end
581
+
582
+ # Format the weights
583
+ weights = user_data[:contribution_weights] || {}
584
+ loc_weight = weights["lines_of_code"].to_i || weights[:lines_of_code].to_i || 0
585
+ complexity_weight = weights["complexity"].to_i || weights[:complexity].to_i || 0
586
+ depth_weight = weights["technical_depth"].to_i || weights[:technical_depth].to_i || 0
587
+ scope_weight = weights["scope"].to_i || weights[:scope].to_i || 0
588
+ pr_weight = weights["pr_reviews"].to_i || weights[:pr_reviews].to_i || 0
589
+
590
+ weights_display = "LOC: #{loc_weight} | Complexity: #{complexity_weight} | Depth: #{depth_weight} | Scope: #{scope_weight} | PR: #{pr_weight}"
591
+
592
+ user_rows << "| **#{username}** | #{commits} | #{pr_reviews} | #{lines_changed} | #{score_display} | #{weights_display} | #{project_names} |"
593
+ end
594
+
595
+ # Create the table with headers and all rows
596
+ <<~MARKDOWN
597
+ ## Active Users
598
+
599
+ Users are sorted by their total contribution score, which is calculated as the sum of individual contribution weights.
600
+ Each contribution weight is on a scale of 0-10 and considers different aspects of contribution value.
601
+
602
+ | User | Commits | PR Reviews | Lines Changed | Total Score | Contribution Weights | Projects |
603
+ |------|---------|------------|---------------|-------------|----------------------|----------|
604
+ #{user_rows.join("\n")}
605
+
606
+ MARKDOWN
607
+ end
608
+
609
+ # Process data from all organizations and extract user activity information
610
+ def self.process_data(results, logger = nil)
611
+ logger ||= Logger.new($stdout)
612
+
613
+ # Combined user activity across all organizations
614
+ combined_users = {}
615
+
616
+ # Users with Gemini-analyzed activity data
617
+ users_with_gemini_activity = {}
618
+
619
+ # Collect all users with commit data
620
+ commits_by_user = {}
621
+
622
+ # Skip specific keys that aren't organizations
623
+ skip_keys = ["_meta", :_meta, "summary_statistics", :summary_statistics]
624
+
625
+ # Get all organizations from the results
626
+ organizations = results.keys.select do |k|
627
+ !skip_keys.include?(k)
628
+ end
629
+
630
+ # Collect all users across all organizations
631
+ organizations.each do |org_name|
632
+ next if skip_keys.include?(org_name)
633
+
634
+ # Skip if this is not an organization data hash
635
+ next unless results[org_name].is_a?(Hash)
636
+
637
+ if results[org_name].key?("users")
638
+ org_users = results[org_name]["users"]
639
+ logger.info("Processing organization #{org_name} with #{org_users.keys.size} users")
640
+
641
+ org_users.each do |username, user_data|
642
+ next if username == :_meta || username == "_meta" # Skip metadata entries
643
+ next unless user_data.is_a?(Hash)
644
+
645
+ combined_users[username] ||= {
646
+ commits: 0,
647
+ pr_reviews: 0,
648
+ projects: Set.new,
649
+ organizations: Set.new,
650
+ contribution_score: 0,
651
+ lines_changed: 0,
652
+ }
653
+
654
+ # Add organization name
655
+ combined_users[username][:organizations].add(org_name)
656
+
657
+ # Add commit count - ensure it's an integer
658
+ commits = user_data[:changes].to_i rescue 0
659
+ commits = user_data["changes"].to_i if commits == 0 && user_data["changes"]
660
+
661
+ combined_users[username][:commits] += commits
662
+
663
+ # Track commits by user
664
+ if commits > 0
665
+ commits_by_user[username] ||= 0
666
+ commits_by_user[username] += commits
667
+ end
668
+
669
+ # Extract PR count and add it
670
+ pr_count = user_data[:pr_count].to_i rescue 0
671
+ pr_count = user_data["pr_count"].to_i if pr_count == 0 && user_data["pr_count"]
672
+
673
+ combined_users[username][:pr_reviews] += pr_count
674
+
675
+ # Extract total score if available
676
+ if user_data[:total_score].to_i > 0
677
+ combined_users[username][:contribution_score] = [
678
+ combined_users[username][:contribution_score],
679
+ user_data[:total_score].to_i
680
+ ].max
681
+ elsif user_data["total_score"].to_i > 0
682
+ combined_users[username][:contribution_score] = [
683
+ combined_users[username][:contribution_score],
684
+ user_data["total_score"].to_i
685
+ ].max
686
+ end
687
+
688
+ # Extract lines changed
689
+ lines_changed = user_data[:lines_changed].to_i rescue 0
690
+ lines_changed = user_data["lines_changed"].to_i if lines_changed == 0 && user_data["lines_changed"]
691
+
692
+ combined_users[username][:lines_changed] += lines_changed
693
+
694
+ # Extract projects data
695
+ if user_data[:projects] || user_data["projects"]
696
+ extracted_projects = user_data[:projects] || user_data["projects"] || []
697
+ if extracted_projects.is_a?(Array)
698
+ extracted_projects.each do |project|
699
+ combined_users[username][:projects].add(project) if project && !project.empty?
700
+ end
701
+ elsif extracted_projects.is_a?(String)
702
+ combined_users[username][:projects].add(extracted_projects) if !extracted_projects.empty?
703
+ end
704
+ end
705
+
706
+ # Check for Gemini activity data
707
+ has_activity = commits > 0 ||
708
+ combined_users[username][:contribution_score] > 0 ||
709
+ lines_changed > 0 ||
710
+ pr_count > 0
711
+
712
+ # Save Gemini analysis data if it exists
713
+ if has_activity
714
+ logger.info("User #{username} has Gemini activity data")
715
+
716
+ users_with_gemini_activity[username] ||= {
717
+ changes: 0,
718
+ contribution_score: 0,
719
+ lines_changed: 0,
720
+ pr_count: 0,
721
+ projects: [],
722
+ summary: "",
723
+ org_name: org_name,
724
+ contribution_weights: {
725
+ "lines_of_code" => 0,
726
+ "complexity" => 0,
727
+ "technical_depth" => 0,
728
+ "scope" => 0,
729
+ "pr_reviews" => 0
730
+ }
731
+ }
732
+
733
+ # Update with this organization's data
734
+ users_with_gemini_activity[username][:changes] += commits if commits > 0
735
+ users_with_gemini_activity[username][:pr_count] += pr_count if pr_count > 0
736
+
737
+ # Extract total score if available
738
+ if user_data[:total_score].to_i > 0
739
+ users_with_gemini_activity[username][:contribution_score] = [
740
+ users_with_gemini_activity[username][:contribution_score],
741
+ user_data[:total_score].to_i
742
+ ].max
743
+ elsif user_data["total_score"].to_i > 0
744
+ users_with_gemini_activity[username][:contribution_score] = [
745
+ users_with_gemini_activity[username][:contribution_score],
746
+ user_data["total_score"].to_i
747
+ ].max
748
+ end
749
+
750
+ users_with_gemini_activity[username][:lines_changed] += lines_changed if lines_changed > 0
751
+
752
+ # Extract contribution weights if they exist
753
+ if user_data[:contribution_weights].is_a?(Hash) || user_data["contribution_weights"].is_a?(Hash)
754
+ weights = user_data[:contribution_weights] || user_data["contribution_weights"]
755
+
756
+ if weights.is_a?(Hash)
757
+ # Copy each weight, using the highest value if it already exists
758
+ ["lines_of_code", "complexity", "technical_depth", "scope", "pr_reviews"].each do |key|
759
+ # Try string or symbol key in the source weights
760
+ weight_value = weights[key].to_i rescue weights[key.to_sym].to_i rescue 0
761
+
762
+ # Update if the new value is higher
763
+ current_value = users_with_gemini_activity[username][:contribution_weights][key].to_i
764
+ if weight_value > current_value
765
+ users_with_gemini_activity[username][:contribution_weights][key] = weight_value
766
+ end
767
+ end
768
+
769
+ logger.debug("Updated contribution_weights for #{username}: #{users_with_gemini_activity[username][:contribution_weights].inspect}")
770
+ end
771
+ end
772
+
773
+ # Calculate total score if not already set
774
+ if users_with_gemini_activity[username][:contribution_score] == 0
775
+ total = 0
776
+ users_with_gemini_activity[username][:contribution_weights].each do |key, value|
777
+ total += value.to_i
778
+ end
779
+ users_with_gemini_activity[username][:contribution_score] = total
780
+ logger.debug("Calculated total score for #{username}: #{total}")
781
+ end
782
+
783
+ # Extract summary
784
+ if user_data[:summary] || user_data["summary"]
785
+ summary = user_data[:summary] || user_data["summary"]
786
+ if summary && !summary.empty? && summary != "No activity detected in the specified time window."
787
+ users_with_gemini_activity[username][:summary] = summary
788
+ end
789
+ end
790
+
791
+ # Add projects
792
+ if user_data[:projects] || user_data["projects"]
793
+ extracted_projects = user_data[:projects] || user_data["projects"] || []
794
+ if extracted_projects.is_a?(Array)
795
+ users_with_gemini_activity[username][:projects] += extracted_projects
796
+ elsif extracted_projects.is_a?(String) && !extracted_projects.empty?
797
+ users_with_gemini_activity[username][:projects] << extracted_projects
798
+ end
799
+
800
+ # Ensure uniqueness of projects
801
+ users_with_gemini_activity[username][:projects].uniq!
802
+ end
803
+ end
804
+ end
805
+ end
806
+ end
807
+
808
+ # Get all active users (those with any activity)
809
+ active_users = combined_users.keys.reject { |username| username == :_meta }
810
+
811
+ # Get inactive users (those without activity in active_users)
812
+ inactive_users = combined_users.keys.reject { |username| username == :_meta }
813
+
814
+ # Return all processed data
815
+ [combined_users, users_with_gemini_activity, inactive_users, commits_by_user]
816
+ end
817
+ end
818
+ end