git_ownership_insights 1.0.6 → 1.0.7
Sign up to get free protection for your applications and to get access to all the features.
- 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
|