git_ownership_insights 1.0.6 → 1.0.7
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 +4 -4
- data/bin/git_ownership_insights +20 -237
- data/lib/git_ownership_insights/git_ownership_insight.rb +232 -0
- data/lib/git_ownership_insights/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: fe758dc63a0e4961606d39b2c484581613564a1ef1fd3644add238a1cd934f6c
|
4
|
+
data.tar.gz: a8a442b6c563f81df7342d3a796e2244cbdb8a66215af52ed6be34a4c629b388
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8fbcfe9390bcabac1dc9b716958baf9df93af32640ce309e31f007a6c0dcab69824060c2c6f682e0f52372da0560a9fa78af0b3844b13fc200e28d660a17f79e
|
7
|
+
data.tar.gz: 13da08836e586de38dd186bf7054408b10253ddf1cd84a04a4ab305c723efba64b0cf91dfd7e090e16a6347abb7e27bc562ae0b1787e74b917dc4e04f76e87b5
|
data/bin/git_ownership_insights
CHANGED
@@ -5,6 +5,7 @@ require 'date'
|
|
5
5
|
require 'optparse'
|
6
6
|
require 'pry'
|
7
7
|
require_relative '../lib/git_ownership_insights/version'
|
8
|
+
require_relative '../lib/git_ownership_insights/git_ownership_insight'
|
8
9
|
|
9
10
|
options = {}
|
10
11
|
OptionParser.new do |opts|
|
@@ -26,11 +27,13 @@ OptionParser.new do |opts|
|
|
26
27
|
options[:hotspot_files] = true
|
27
28
|
end
|
28
29
|
|
29
|
-
opts.on('--excluded-contributors STRING',
|
30
|
+
opts.on('--excluded-contributors STRING',
|
31
|
+
'Comma-delimited list of excluded contributors [example: WEB,RAILS,MOBILE]') do |exclusions|
|
30
32
|
options[:exclusions] = exclusions
|
31
33
|
end
|
32
34
|
|
33
|
-
opts.on('--excluded-files STRING',
|
35
|
+
opts.on('--excluded-files STRING',
|
36
|
+
'Comma-delimited list of excluded files [example: ViewController,AppDelegate.swift]') do |excluded_files|
|
34
37
|
options[:excluded_files] = excluded_files
|
35
38
|
end
|
36
39
|
|
@@ -51,11 +54,13 @@ OptionParser.new do |opts|
|
|
51
54
|
options[:team_regex] = team_regex
|
52
55
|
end
|
53
56
|
|
54
|
-
opts.on('--top-contributing-team STRING',
|
57
|
+
opts.on('--top-contributing-team STRING',
|
58
|
+
'Limit of top contributed to the directory teams in codeownership data [default: 5]') do |top_contributing_team|
|
55
59
|
options[:top_contributing_team] = top_contributing_team
|
56
60
|
end
|
57
61
|
|
58
|
-
opts.on('--top-touched-files STRING',
|
62
|
+
opts.on('--top-touched-files STRING',
|
63
|
+
'Limit of top touched files by individual contributors in codeownership data [default: 5]') do |top_touched_files|
|
59
64
|
options[:top_touched_files] = top_touched_files
|
60
65
|
end
|
61
66
|
|
@@ -63,15 +68,18 @@ OptionParser.new do |opts|
|
|
63
68
|
options[:codeowners_path] = codeowners_path
|
64
69
|
end
|
65
70
|
|
66
|
-
opts.on('--big-file-size STRING',
|
71
|
+
opts.on('--big-file-size STRING',
|
72
|
+
'The amount of lines in the file to be considered big [default: 250]') do |big_file_size|
|
67
73
|
options[:big_file_size] = big_file_size
|
68
74
|
end
|
69
75
|
|
70
|
-
opts.on('--default-branch STRING',
|
76
|
+
opts.on('--default-branch STRING',
|
77
|
+
'The default branch to pull and run metrics for [default: master]') do |default_branch|
|
71
78
|
options[:default_branch] = default_branch
|
72
79
|
end
|
73
80
|
|
74
|
-
opts.on('--code-extensions STRING',
|
81
|
+
opts.on('--code-extensions STRING',
|
82
|
+
'The file extensions that consider to be code [default: ".kt, .swift"]') do |code_extension|
|
75
83
|
options[:code_extension] = code_extension
|
76
84
|
end
|
77
85
|
|
@@ -96,7 +104,7 @@ REPO_PATH = options[:path] || '.'
|
|
96
104
|
TEAM_REGEX = options[:team_regex] || '[A-Za-z]+'
|
97
105
|
TOP_TOUCHED_FILES = options[:top_touched_files] || 5
|
98
106
|
TOP_CONTRIBUTED_TEAMS = options[:top_contributing_team] || 5
|
99
|
-
CODEOWNERS_PATH =
|
107
|
+
CODEOWNERS_PATH = options[:codeowners_path] || '.github/CODEOWNERS'
|
100
108
|
BIG_FILE_SIZE = options[:big_file_size] || 250
|
101
109
|
CI = options[:ci] || false
|
102
110
|
DEFAULT_BRANCH = options[:default_branch] || 'master'
|
@@ -105,231 +113,6 @@ HOTSPOT = options[:hotspot_files] || false
|
|
105
113
|
CODE_EXTENSIONS = options[:code_extension] ? options[:code_extension].split : ['.swift', '.kt']
|
106
114
|
EXCLUDED_FILES = options[:excluded_files]
|
107
115
|
|
108
|
-
def true?(obj)
|
109
|
-
obj.to_s.downcase == "true"
|
110
|
-
end
|
111
|
-
|
112
|
-
def read_codeowners_file
|
113
|
-
raise "CODEOWNERS file does not exist under #{CODEOWNERS_PATH}" unless File.exist?(CODEOWNERS_PATH)
|
114
|
-
|
115
|
-
codeowners = {}
|
116
|
-
File.readlines(CODEOWNERS_PATH).each do |line|
|
117
|
-
next if line.strip.empty? || line.start_with?('#') # Skip comments and empty lines
|
118
|
-
|
119
|
-
parts = line.split(/\s+/)
|
120
|
-
directory_pattern = parts[0]
|
121
|
-
owner = parts[1..].map { |o| o.start_with?('@') ? o[1..] : o }.join(' ') # Remove leading '@' from team names
|
122
|
-
codeowners[directory_pattern] = owner
|
123
|
-
end
|
124
|
-
codeowners
|
125
|
-
end
|
126
|
-
|
127
|
-
def find_owners(file_path, codeowners)
|
128
|
-
matching_patterns = codeowners.keys.select do |pattern|
|
129
|
-
pattern_regex = Regexp.new("^#{Regexp.escape(pattern.sub(%r{^/+}, '').chomp('/')).gsub('\*', '.*').gsub('**', '.*?')}")
|
130
|
-
file_path =~ pattern_regex
|
131
|
-
end
|
132
|
-
|
133
|
-
return ['unknown'] if matching_patterns.empty?
|
134
|
-
|
135
|
-
# Sort patterns by length in descending order
|
136
|
-
sorted_patterns = matching_patterns.sort_by(&:length).reverse
|
137
|
-
|
138
|
-
# Find the most specific matching pattern
|
139
|
-
best_match = sorted_patterns.find do |pattern|
|
140
|
-
pattern_regex = Regexp.new("^#{Regexp.escape(pattern.sub(%r{^/+}, '').chomp('/')).gsub('\*', '.*').gsub('**', '.*?')}")
|
141
|
-
file_path =~ pattern_regex
|
142
|
-
end
|
143
|
-
|
144
|
-
codeowners[best_match].split(' ')
|
145
|
-
end
|
146
|
-
|
147
|
-
def count_big_files(directory_path, size: BIG_FILE_SIZE)
|
148
|
-
size = size.to_i
|
149
|
-
# Get a list of all files in the specified directory
|
150
|
-
files = Dir.glob(File.join(directory_path, '**', '*')).select { |file| File.file?(file) }
|
151
|
-
|
152
|
-
code_files = files.select {|f|
|
153
|
-
extension = File.extname(f)
|
154
|
-
valid_extensions = ['.swift', '.kt']
|
155
|
-
valid_extensions.include?(extension)
|
156
|
-
}
|
157
|
-
|
158
|
-
# Initialize a counter for files that meet the criteria
|
159
|
-
count = 0
|
160
|
-
|
161
|
-
# Iterate through each file and check the line count
|
162
|
-
code_files.each do |file|
|
163
|
-
lines_count = File.foreach(file).reject { |line| line.match(/^\s*(\/\/|\/\*.*\*\/|\s*$)/) }.count
|
164
|
-
|
165
|
-
if lines_count > size
|
166
|
-
count += 1
|
167
|
-
end
|
168
|
-
end
|
169
|
-
|
170
|
-
puts " Total number of code files longer than #{size} lines: #{count}"
|
171
|
-
end
|
172
|
-
|
173
|
-
def count_hotspot_lines(files)
|
174
|
-
code_files = files.select {|f|
|
175
|
-
extension = File.extname(f)
|
176
|
-
valid_extensions = ['.swift', '.kt']
|
177
|
-
valid_extensions.include?(extension)
|
178
|
-
}
|
179
|
-
|
180
|
-
# Initialize a counter for files that meet the criteria
|
181
|
-
count = 0
|
182
|
-
|
183
|
-
# Iterate through each file and check the line count
|
184
|
-
code_files.each do |file|
|
185
|
-
lines_count = File.foreach(file).reject { |line| line.match(/^\s*(\/\/|\/\*.*\*\/|\s*$)/) }.count
|
186
|
-
|
187
|
-
count += lines_count
|
188
|
-
end
|
189
|
-
|
190
|
-
puts " Total lines of hotspot code: #{count}"
|
191
|
-
end
|
192
|
-
|
193
|
-
def contribution_message(directory_path:, duration_in_days:, begin_time:, debug: nil, steps: nil)
|
194
|
-
duration_in_days = duration_in_days.to_i
|
195
|
-
all_teams = []
|
196
|
-
teams_count = 0
|
197
|
-
files_changed_by_many_teams = 0
|
198
|
-
total_changes = 0
|
199
|
-
start_date = begin_time.to_time.to_i - duration_in_days * 86_400
|
200
|
-
end_date = begin_time.to_time.to_i
|
201
|
-
file_count = `git ls-tree -r --name-only $(git rev-list -1 --since="#{start_date}" --until="#{end_date}" HEAD) -- "#{directory_path}" | wc -l`.to_i
|
202
|
-
all_files_with_changes = `git log --name-only --pretty=format:"" --since="#{start_date}" --until="#{end_date}" "#{directory_path}"`.split.sort
|
203
|
-
excluded_patterns = EXCLUDED_FILES.split(',') if EXCLUDED_FILES
|
204
|
-
|
205
|
-
code_files_with_changes = all_files_with_changes.select {|f|
|
206
|
-
extension = File.extname(f)
|
207
|
-
valid_extensions = CODE_EXTENSIONS
|
208
|
-
valid_extensions.include?(extension)
|
209
|
-
}
|
210
|
-
|
211
|
-
if EXCLUDED_FILES
|
212
|
-
code_files_with_changes = code_files_with_changes.reject do |file|
|
213
|
-
excluded_patterns.any? { |pattern| file.include?(pattern) }
|
214
|
-
end
|
215
|
-
end
|
216
|
-
|
217
|
-
uniq_code_files_with_changes = code_files_with_changes.uniq
|
218
|
-
|
219
|
-
file_team_map = {}
|
220
|
-
uniq_code_files_with_changes.each do |file|
|
221
|
-
filename = File.basename(file)
|
222
|
-
commit_count = `git log --since="#{start_date}" --until="#{end_date}" --follow -- "#{file}" | grep -c '^commit'`.to_i
|
223
|
-
|
224
|
-
# Get the log of the file in the given duration
|
225
|
-
git_log = `git log --pretty=format:"%s" --since="#{start_date}" --until="#{end_date}" --follow -- "#{file}"`.split("\n")
|
226
|
-
teams = git_log.map do |team|
|
227
|
-
team.match(/#{TEAM_REGEX}/)[0].upcase
|
228
|
-
end.reject { |e| EXCLUSIONS&.include?(e) }
|
229
|
-
|
230
|
-
total_changes += commit_count
|
231
|
-
all_teams << teams
|
232
|
-
teams = teams.uniq
|
233
|
-
|
234
|
-
if teams.count > 1
|
235
|
-
files_changed_by_many_teams += 1
|
236
|
-
file_team_map.merge!("#{file}" => [teams, commit_count])
|
237
|
-
teams_count += teams.count
|
238
|
-
end
|
239
|
-
|
240
|
-
puts "\n#{filename} [#{commit_count}]:#{teams}\n" if debug
|
241
|
-
end
|
242
|
-
|
243
|
-
occurrences = all_teams.flatten.compact.tally
|
244
|
-
sorted_occurrences = occurrences.sort_by { |element, count| [-count, element] }
|
245
|
-
contributors = Hash[sorted_occurrences]
|
246
|
-
|
247
|
-
churn_count = file_team_map.values.map { |value| value[1] }.sum
|
248
|
-
hotspot_changes_percentage = (churn_count.to_f / total_changes.to_f)*100
|
249
|
-
|
250
|
-
# Filter files based on extension and size
|
251
|
-
filtered_files = file_team_map.select do |file_path|
|
252
|
-
next unless File.exist?(file_path)
|
253
|
-
|
254
|
-
# Check if the file size is more than BIG_FILE_SIZE lines (excluding empty and commented lines)
|
255
|
-
File.foreach(file_path).reject { |line| line.match(/^\s*(\/\/|\/\*.*\*\/|\s*$)/) }.count > BIG_FILE_SIZE.to_i
|
256
|
-
end
|
257
|
-
|
258
|
-
filtered_top_touched_files = filtered_files.sort_by { |element, count| [-count.last, element] }
|
259
|
-
|
260
|
-
puts "Timeframe: #{(begin_time - duration_in_days).strftime('%Y-%m-%d')} to #{begin_time.strftime('%Y-%m-%d')}"
|
261
|
-
puts " Hotspot Code Changes: #{churn_count} (#{hotspot_changes_percentage.round(2)}%)"
|
262
|
-
puts " Cross-Squad File Dependencies Count: #{teams_count}"
|
263
|
-
puts " Files exceeding #{BIG_FILE_SIZE} lines with multiple contributors: #{filtered_top_touched_files.count}"
|
264
|
-
count_hotspot_lines(filtered_files.keys)
|
265
|
-
count_big_files(directory_path)
|
266
|
-
puts " Code files with a single contributor: #{(100 - ((files_changed_by_many_teams.to_f / code_files_with_changes.count.to_f) * 100)).round(2)}%"
|
267
|
-
puts " Total amount of code changes: #{total_changes}"
|
268
|
-
puts " Total files changed: #{code_files_with_changes.count}"
|
269
|
-
puts " Total files in the folder: #{file_count}"
|
270
|
-
puts " Contributors: #{contributors}"
|
271
|
-
|
272
|
-
if HOTSPOT
|
273
|
-
puts "\n"
|
274
|
-
puts " Hotspot changes:"
|
275
|
-
filtered_top_touched_files.each do |line|
|
276
|
-
puts " #{line.first.gsub(directory_path, '')} Contributors: #{line.last.first} Commits: #{line.last.last}"
|
277
|
-
end
|
278
|
-
end
|
279
|
-
|
280
|
-
|
281
|
-
if CODEOWNERS
|
282
|
-
puts "\n"
|
283
|
-
puts "Code ownership data:"
|
284
|
-
codeowners = read_codeowners_file
|
285
|
-
|
286
|
-
owners_data = Hash.new do |hash, key|
|
287
|
-
hash[key] = { directories: Hash.new do |h, k|
|
288
|
-
h[k] = { files: [] }
|
289
|
-
end, churn_count: 0 }
|
290
|
-
end
|
291
|
-
|
292
|
-
file_team_map.each do |file, count|
|
293
|
-
owners = find_owners(file, codeowners)
|
294
|
-
owners.each do |owner|
|
295
|
-
owners_data[owner][:churn_count] += count.last
|
296
|
-
|
297
|
-
dir_path = File.dirname(file)
|
298
|
-
owners_data[owner][:directories][dir_path][:files] << { name: File.basename(file), count: count }
|
299
|
-
end
|
300
|
-
end
|
301
|
-
|
302
|
-
# Sort owners_data by total count in descending order
|
303
|
-
sorted_owners_data = owners_data.sort_by { |_, data| -data[:churn_count] }
|
304
|
-
|
305
|
-
# Take the last 5 elements
|
306
|
-
top_owners_data = sorted_owners_data.last(TOP_CONTRIBUTED_TEAMS.to_i)
|
307
|
-
|
308
|
-
converted_team_map = file_team_map.transform_keys { |key| File.basename(key) }
|
309
|
-
|
310
|
-
puts ' Codeownership data:'
|
311
|
-
top_owners_data.each do |owner, data|
|
312
|
-
puts " #{owner.split('/').last}:\n Total Count: #{data[:churn_count]}"
|
313
|
-
data[:directories].each do |dir, dir_data|
|
314
|
-
puts " Directory: #{dir}\n Top files:"
|
315
|
-
dir_data[:files].each do |file_data|
|
316
|
-
next if converted_team_map[File.basename(file_data[:name])].nil?
|
317
|
-
|
318
|
-
contributors = converted_team_map[file_data[:name]]&.first&.empty? ? [ "Excluded contributor" ] : converted_team_map[file_data[:name]].first
|
319
|
-
puts " #{File.basename(file_data[:name])} - #{file_data[:count].last} #{contributors}}"
|
320
|
-
end
|
321
|
-
end
|
322
|
-
end
|
323
|
-
end
|
324
|
-
steps -= 1
|
325
|
-
|
326
|
-
return unless steps.positive?
|
327
|
-
|
328
|
-
system("git checkout `git rev-list -1 --before='#{(begin_time - duration_in_days).strftime("%B %d %Y")}' HEAD`", [ :out, :err ] => File::NULL)
|
329
|
-
contribution_message(duration_in_days: duration_in_days, directory_path: directory_path,
|
330
|
-
begin_time: begin_time - duration_in_days, steps: steps, debug: debug)
|
331
|
-
end
|
332
|
-
|
333
116
|
unless CI
|
334
117
|
puts "\nDirectory: #{REPO_PATH}\n"
|
335
118
|
puts "Time period that data is aggregated by: #{options[:duration_in_days]} days"
|
@@ -348,8 +131,8 @@ unless CI
|
|
348
131
|
puts "Debug mode is: #{options[:debug] ? 'on' : 'off'}\n\n"
|
349
132
|
end
|
350
133
|
|
351
|
-
system("git checkout #{DEFAULT_BRANCH}", [
|
352
|
-
system(
|
134
|
+
system("git checkout #{DEFAULT_BRANCH}", [:out] => File::NULL)
|
135
|
+
system('git pull', %i[out err] => File::NULL)
|
353
136
|
|
354
|
-
contribution_message(duration_in_days: options[:duration_in_days] || 30, directory_path: REPO_PATH,
|
355
|
-
|
137
|
+
GitOwnershipInsights.contribution_message(duration_in_days: options[:duration_in_days] || 30, directory_path: REPO_PATH,
|
138
|
+
begin_time: DateTime.now, steps: options[:steps].to_i, debug: options[:debug])
|
@@ -0,0 +1,232 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GitOwnershipInsights
|
4
|
+
def self.true?(obj)
|
5
|
+
obj.to_s.downcase == 'true'
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.read_codeowners_file
|
9
|
+
raise "CODEOWNERS file does not exist under #{CODEOWNERS_PATH}" unless File.exist?(CODEOWNERS_PATH)
|
10
|
+
|
11
|
+
codeowners = {}
|
12
|
+
File.readlines(CODEOWNERS_PATH).each do |line|
|
13
|
+
next if line.strip.empty? || line.start_with?('#') # Skip comments and empty lines
|
14
|
+
|
15
|
+
parts = line.split(/\s+/)
|
16
|
+
directory_pattern = parts[0]
|
17
|
+
owner = parts[1..].map { |o| o.start_with?('@') ? o[1..] : o }.join(' ') # Remove leading '@' from team names
|
18
|
+
codeowners[directory_pattern] = owner
|
19
|
+
end
|
20
|
+
codeowners
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.find_owners(file_path, codeowners)
|
24
|
+
matching_patterns = codeowners.keys.select do |pattern|
|
25
|
+
pattern_regex = Regexp.new("^#{Regexp.escape(pattern.sub(%r{^/+}, '').chomp('/')).gsub('\*', '.*').gsub('**',
|
26
|
+
'.*?')}")
|
27
|
+
file_path =~ pattern_regex
|
28
|
+
end
|
29
|
+
|
30
|
+
return ['unknown'] if matching_patterns.empty?
|
31
|
+
|
32
|
+
# Sort patterns by length in descending order
|
33
|
+
sorted_patterns = matching_patterns.sort_by(&:length).reverse
|
34
|
+
|
35
|
+
# Find the most specific matching pattern
|
36
|
+
best_match = sorted_patterns.find do |pattern|
|
37
|
+
pattern_regex = Regexp.new("^#{Regexp.escape(pattern.sub(%r{^/+}, '').chomp('/')).gsub('\*', '.*').gsub('**',
|
38
|
+
'.*?')}")
|
39
|
+
file_path =~ pattern_regex
|
40
|
+
end
|
41
|
+
|
42
|
+
codeowners[best_match].split(' ')
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.count_big_files(directory_path, size: BIG_FILE_SIZE)
|
46
|
+
size = size.to_i
|
47
|
+
# Get a list of all files in the specified directory
|
48
|
+
files = Dir.glob(File.join(directory_path, '**', '*')).select { |file| File.file?(file) }
|
49
|
+
|
50
|
+
code_files = files.select do |f|
|
51
|
+
extension = File.extname(f)
|
52
|
+
valid_extensions = ['.swift', '.kt']
|
53
|
+
valid_extensions.include?(extension)
|
54
|
+
end
|
55
|
+
|
56
|
+
# Initialize a counter for files that meet the criteria
|
57
|
+
count = 0
|
58
|
+
# Iterate through each file and check the line count
|
59
|
+
code_files.each do |file|
|
60
|
+
lines_count = File.foreach(file).reject { |line| line.match(%r{^\s*(//|/\*.*\*/|\s*$)}) }.count
|
61
|
+
|
62
|
+
count += 1 if lines_count > size
|
63
|
+
end
|
64
|
+
|
65
|
+
puts " Total number of code files longer than #{size} lines: #{count}"
|
66
|
+
end
|
67
|
+
|
68
|
+
def self.count_hotspot_lines(files)
|
69
|
+
code_files = files.select do |f|
|
70
|
+
extension = File.extname(f)
|
71
|
+
valid_extensions = ['.swift', '.kt']
|
72
|
+
valid_extensions.include?(extension)
|
73
|
+
end
|
74
|
+
|
75
|
+
# Initialize a counter for files that meet the criteria
|
76
|
+
count = 0
|
77
|
+
|
78
|
+
# Iterate through each file and check the line count
|
79
|
+
code_files.each do |file|
|
80
|
+
lines_count = File.foreach(file).reject { |line| line.match(%r{^\s*(//|/\*.*\*/|\s*$)}) }.count
|
81
|
+
|
82
|
+
count += lines_count
|
83
|
+
end
|
84
|
+
|
85
|
+
puts " Total lines of hotspot code: #{count}"
|
86
|
+
end
|
87
|
+
|
88
|
+
def self.contribution_message(directory_path:, duration_in_days:, begin_time:, debug: nil, steps: nil)
|
89
|
+
duration_in_days = duration_in_days.to_i
|
90
|
+
all_teams = []
|
91
|
+
cross_teams_count = 0
|
92
|
+
single_ownership_teams_count = 0
|
93
|
+
files_changed_by_many_teams = 0
|
94
|
+
total_changes = 0
|
95
|
+
start_date = begin_time.to_time.to_i - duration_in_days * 86_400
|
96
|
+
end_date = begin_time.to_time.to_i
|
97
|
+
file_count = `git ls-tree -r --name-only $(git rev-list -1 --since="#{start_date}" --until="#{end_date}" HEAD) -- "#{directory_path}" | wc -l`.to_i
|
98
|
+
all_files_with_changes = `git log --name-only --pretty=format:"" --since="#{start_date}" --until="#{end_date}" "#{directory_path}"`.split.sort
|
99
|
+
excluded_patterns = EXCLUDED_FILES.split(',') if EXCLUDED_FILES
|
100
|
+
|
101
|
+
code_files_with_changes = all_files_with_changes.select do |f|
|
102
|
+
extension = File.extname(f)
|
103
|
+
valid_extensions = CODE_EXTENSIONS
|
104
|
+
valid_extensions.include?(extension)
|
105
|
+
end
|
106
|
+
|
107
|
+
if EXCLUDED_FILES
|
108
|
+
code_files_with_changes = code_files_with_changes.reject do |file|
|
109
|
+
excluded_patterns.any? { |pattern| file.include?(pattern) }
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
uniq_code_files_with_changes = code_files_with_changes.uniq
|
114
|
+
|
115
|
+
file_team_map = {}
|
116
|
+
uniq_code_files_with_changes.each do |file|
|
117
|
+
filename = File.basename(file)
|
118
|
+
commit_count = `git log --since="#{start_date}" --until="#{end_date}" --follow -- "#{file}" | grep -c '^commit'`.to_i
|
119
|
+
# Get the log of the file in the given duration
|
120
|
+
git_log = `git log --pretty=format:"%s" --since="#{start_date}" --until="#{end_date}" --follow -- "#{file}"`.split("\n")
|
121
|
+
teams = git_log.map do |team|
|
122
|
+
team.match(/#{TEAM_REGEX}/)[0].upcase
|
123
|
+
end.reject { |e| EXCLUSIONS&.include?(e) }
|
124
|
+
|
125
|
+
total_changes += commit_count
|
126
|
+
all_teams << teams
|
127
|
+
teams = teams.uniq
|
128
|
+
|
129
|
+
if teams.count > 1
|
130
|
+
files_changed_by_many_teams += 1
|
131
|
+
file_team_map.merge!(file.to_s => [teams, commit_count])
|
132
|
+
cross_teams_count += teams.count if File.exist?(file)
|
133
|
+
else
|
134
|
+
single_ownership_teams_count += 1 if File.exist?(file)
|
135
|
+
end
|
136
|
+
|
137
|
+
puts "\n#{filename} [#{commit_count}]:#{teams}\n" if debug
|
138
|
+
end
|
139
|
+
|
140
|
+
occurrences = all_teams.flatten.compact.tally
|
141
|
+
sorted_occurrences = occurrences.sort_by { |element, count| [-count, element] }
|
142
|
+
contributors = Hash[sorted_occurrences]
|
143
|
+
|
144
|
+
churn_count = file_team_map.values.map { |value| value[1] }.sum
|
145
|
+
hotspot_changes_percentage = (churn_count.to_f / total_changes) * 100
|
146
|
+
|
147
|
+
# Filter files based on extension and size
|
148
|
+
filtered_files = file_team_map.select do |file_path|
|
149
|
+
next unless File.exist?(file_path)
|
150
|
+
|
151
|
+
# Check if the file size is more than BIG_FILE_SIZE lines (excluding empty and commented lines)
|
152
|
+
File.foreach(file_path).reject { |line| line.match(%r{^\s*(//|/\*.*\*/|\s*$)}) }.count > BIG_FILE_SIZE.to_i
|
153
|
+
end
|
154
|
+
|
155
|
+
filtered_top_touched_files = filtered_files.sort_by { |element, count| [-count.last, element] }
|
156
|
+
|
157
|
+
puts ""
|
158
|
+
puts "Timeframe: #{(begin_time - duration_in_days).strftime('%Y-%m-%d')} to #{begin_time.strftime('%Y-%m-%d')}"
|
159
|
+
puts " Hotspot Code Changes: #{churn_count} (#{hotspot_changes_percentage.round(2)}%)"
|
160
|
+
puts " Cross-Squad Dependency:"
|
161
|
+
puts " Squads depending on each other occurrences: #{cross_teams_count}"
|
162
|
+
puts " Squads with single responsibility: #{single_ownership_teams_count}"
|
163
|
+
puts " Files exceeding #{BIG_FILE_SIZE} lines with multiple contributors: #{filtered_top_touched_files.count}"
|
164
|
+
count_hotspot_lines(filtered_files.keys)
|
165
|
+
count_big_files(directory_path)
|
166
|
+
puts " Code files with a single contributor: #{(100 - ((files_changed_by_many_teams.to_f / code_files_with_changes.count) * 100)).round(2)}%"
|
167
|
+
puts " Total amount of code changes: #{total_changes}"
|
168
|
+
puts " Total files changed: #{code_files_with_changes.count}"
|
169
|
+
puts " Total files in the folder: #{file_count}"
|
170
|
+
puts " Contributors: #{contributors}"
|
171
|
+
|
172
|
+
if HOTSPOT
|
173
|
+
puts "\n"
|
174
|
+
puts ' Hotspot changes:'
|
175
|
+
filtered_top_touched_files.each do |line|
|
176
|
+
puts " #{line.first.gsub(directory_path, '')} Contributors: #{line.last.first} Commits: #{line.last.last}"
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
if CODEOWNERS
|
181
|
+
puts "\n"
|
182
|
+
puts 'Code ownership data:'
|
183
|
+
codeowners = read_codeowners_file
|
184
|
+
|
185
|
+
owners_data = Hash.new do |hash, key|
|
186
|
+
hash[key] = { directories: Hash.new do |h, k|
|
187
|
+
h[k] = { files: [] }
|
188
|
+
end, churn_count: 0 }
|
189
|
+
end
|
190
|
+
|
191
|
+
file_team_map.each do |file, count|
|
192
|
+
owners = find_owners(file, codeowners)
|
193
|
+
owners.each do |owner|
|
194
|
+
owners_data[owner][:churn_count] += count.last
|
195
|
+
|
196
|
+
dir_path = File.dirname(file)
|
197
|
+
owners_data[owner][:directories][dir_path][:files] << { name: File.basename(file), count: count }
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
# Sort owners_data by total count in descending order
|
202
|
+
sorted_owners_data = owners_data.sort_by { |_, data| -data[:churn_count] }
|
203
|
+
|
204
|
+
# Take the last 5 elements
|
205
|
+
top_owners_data = sorted_owners_data.last(TOP_CONTRIBUTED_TEAMS.to_i)
|
206
|
+
|
207
|
+
converted_team_map = file_team_map.transform_keys { |key| File.basename(key) }
|
208
|
+
|
209
|
+
puts ' Codeownership data:'
|
210
|
+
top_owners_data.each do |owner, data|
|
211
|
+
puts " #{owner.split('/').last}:\n Total Count: #{data[:churn_count]}"
|
212
|
+
data[:directories].each do |dir, dir_data|
|
213
|
+
puts " Directory: #{dir}\n Top files:"
|
214
|
+
dir_data[:files].each do |file_data|
|
215
|
+
next if converted_team_map[File.basename(file_data[:name])].nil?
|
216
|
+
|
217
|
+
contributors = converted_team_map[file_data[:name]]&.first&.empty? ? ['Excluded contributor'] : converted_team_map[file_data[:name]].first
|
218
|
+
puts " #{File.basename(file_data[:name])} - #{file_data[:count].last} #{contributors}}"
|
219
|
+
end
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
223
|
+
steps -= 1
|
224
|
+
|
225
|
+
return unless steps.positive?
|
226
|
+
|
227
|
+
system("git checkout `git rev-list -1 --before='#{(begin_time - duration_in_days).strftime('%B %d %Y')}' HEAD`",
|
228
|
+
%i[out err] => File::NULL)
|
229
|
+
contribution_message(duration_in_days: duration_in_days, directory_path: directory_path,
|
230
|
+
begin_time: begin_time - duration_in_days, steps: steps, debug: debug)
|
231
|
+
end
|
232
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: git_ownership_insights
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.0.
|
4
|
+
version: 1.0.7
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Serghei Moret
|
@@ -61,6 +61,7 @@ files:
|
|
61
61
|
- bin/setup
|
62
62
|
- git_ownership_insights.gemspec
|
63
63
|
- lib/git_ownership_insights.rb
|
64
|
+
- lib/git_ownership_insights/git_ownership_insight.rb
|
64
65
|
- lib/git_ownership_insights/version.rb
|
65
66
|
- sig/git_ownership_insights.rbs
|
66
67
|
- spec/git_ownership_insights_spec.rb
|