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