git_ownership_insights 0.1.4 → 1.0.2
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/Gemfile.lock +2 -0
- data/bin/git_ownership_insights +182 -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: 2fa8a6909c32117aee3b0472a09e345027761f5e76ba9a3802b3207ec6305dc7
|
4
|
+
data.tar.gz: add3ba1c16614cf20908fdc71188e9ec1b16f672a9953727c78e87171ae1fed9
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e0b2d324e4063100bb30851dc221034d5caab9c1a80c5164ef23dd38e81ed8614f80b901e2f6f307cfd739b4a6481b1146575c340671a8a8a8b35ab01def9ed9
|
7
|
+
data.tar.gz: 0fcb12355a628464d748e191068b67c3e7c09aac0ce3088c70fc3fb3409d55dd54bc2f5d877e871283c6296ea475533ecf69759876f0e84ddf9fe42d30866b04
|
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,44 @@ 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(',') if EXCLUDED_FILES
|
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
|
+
}
|
182
|
+
|
183
|
+
if EXCLUDED_FILES
|
184
|
+
code_files_with_changes = code_files_with_changes.reject do |file|
|
185
|
+
excluded_patterns.any? { |pattern| file.include?(pattern) }
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
uniq_code_files_with_changes = code_files_with_changes.uniq
|
190
|
+
|
103
191
|
file_team_map = {}
|
104
|
-
|
192
|
+
uniq_code_files_with_changes.each do |file|
|
105
193
|
filename = File.basename(file)
|
106
194
|
commit_count = `git log --since="#{start_date}" --until="#{end_date}" --follow -- "#{file}" | grep -c '^commit'`.to_i
|
107
195
|
|
108
|
-
next unless commit_count.positive?
|
109
|
-
|
110
196
|
# Get the log of the file in the given duration
|
111
197
|
git_log = `git log --pretty=format:"%s" --since="#{start_date}" --until="#{end_date}" --follow -- "#{file}"`.split("\n")
|
112
198
|
teams = git_log.map do |team|
|
113
199
|
team.match(/#{TEAM_REGEX}/)[0].upcase
|
114
200
|
end.reject { |e| EXCLUSIONS&.include?(e) }
|
115
|
-
|
201
|
+
|
202
|
+
total_changes += commit_count
|
116
203
|
all_teams << teams
|
117
204
|
teams = teams.uniq
|
118
|
-
|
119
|
-
|
205
|
+
|
206
|
+
if teams.count > 1
|
207
|
+
files_changed_by_many_teams += 1
|
208
|
+
file_team_map.merge!("#{file}" => [teams, commit_count])
|
209
|
+
end
|
210
|
+
|
120
211
|
puts "\n#{filename} [#{commit_count}]:#{teams}\n" if debug
|
121
212
|
end
|
122
213
|
|
@@ -124,61 +215,99 @@ def contribution_message(directory_path:, duration_in_days:, begin_time:, debug:
|
|
124
215
|
sorted_occurrences = occurrences.sort_by { |element, count| [-count, element] }
|
125
216
|
contributors = Hash[sorted_occurrences]
|
126
217
|
|
127
|
-
|
218
|
+
churn_count = file_team_map.values.map { |value| value[1] }.sum
|
219
|
+
hotspot_changes_percentage = (churn_count.to_f / total_changes.to_f)*100
|
128
220
|
|
129
|
-
|
130
|
-
top_touched_files = touched_files.sort_by { |element, count| [-count, element] }.take(TOP_TOUCHED_FILES.to_i)
|
131
|
-
codeowners = read_codeowners_file
|
221
|
+
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
222
|
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
end, total_count: 0 }
|
137
|
-
end
|
223
|
+
# Filter files based on extension and size
|
224
|
+
filtered_files = file_team_map.select do |file_path|
|
225
|
+
next unless File.exist?(file_path)
|
138
226
|
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
owners_data[owner][:total_count] += count
|
227
|
+
# Check if the file size is more than BIG_FILE_SIZE lines (excluding empty and commented lines)
|
228
|
+
File.foreach(file_path).reject { |line| line.match(/^\s*(\/\/|\/\*.*\*\/|\s*$)/) }.count > BIG_FILE_SIZE.to_i
|
229
|
+
end
|
143
230
|
|
144
|
-
|
145
|
-
|
231
|
+
filtered_top_touched_files = filtered_files.sort_by { |element, count| [-count.last, element] }
|
232
|
+
count_big_files(directory_path)
|
233
|
+
puts " Total files longer than #{BIG_FILE_SIZE} lines with multiple contributors: #{filtered_top_touched_files.count}\n"
|
234
|
+
if HOTSPOT
|
235
|
+
filtered_top_touched_files.each do |line|
|
236
|
+
puts " #{line.first.gsub(directory_path, '')} Contributors: #{line.last.first} Commits: #{line.last.last}"
|
146
237
|
end
|
147
238
|
end
|
239
|
+
puts "\n\n"
|
240
|
+
|
241
|
+
if CODEOWNERS
|
242
|
+
codeowners = read_codeowners_file
|
148
243
|
|
149
|
-
|
150
|
-
|
244
|
+
owners_data = Hash.new do |hash, key|
|
245
|
+
hash[key] = { directories: Hash.new do |h, k|
|
246
|
+
h[k] = { files: [] }
|
247
|
+
end, churn_count: 0 }
|
248
|
+
end
|
151
249
|
|
152
|
-
|
153
|
-
|
250
|
+
file_team_map.each do |file, count|
|
251
|
+
owners = find_owners(file, codeowners)
|
252
|
+
owners.each do |owner|
|
253
|
+
owners_data[owner][:churn_count] += count.last
|
154
254
|
|
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]]}"
|
255
|
+
dir_path = File.dirname(file)
|
256
|
+
owners_data[owner][:directories][dir_path][:files] << { name: File.basename(file), count: count }
|
162
257
|
end
|
163
258
|
end
|
164
|
-
end
|
165
259
|
|
260
|
+
# Sort owners_data by total count in descending order
|
261
|
+
sorted_owners_data = owners_data.sort_by { |_, data| -data[:churn_count] }
|
262
|
+
|
263
|
+
# Take the last 5 elements
|
264
|
+
top_owners_data = sorted_owners_data.last(TOP_CONTRIBUTED_TEAMS.to_i)
|
265
|
+
|
266
|
+
converted_team_map = file_team_map.transform_keys { |key| File.basename(key) }
|
267
|
+
|
268
|
+
puts ' Codeownership data:'
|
269
|
+
top_owners_data.each do |owner, data|
|
270
|
+
puts " #{owner.split('/').last}:\n Total Count: #{data[:churn_count]}"
|
271
|
+
data[:directories].each do |dir, dir_data|
|
272
|
+
puts " Directory: #{dir}\n Top files:"
|
273
|
+
dir_data[:files].each do |file_data|
|
274
|
+
next if converted_team_map[File.basename(file_data[:name])].nil?
|
275
|
+
|
276
|
+
contributors = converted_team_map[file_data[:name]]&.first&.empty? ? [ "Excluded contributor" ] : converted_team_map[file_data[:name]].first
|
277
|
+
puts " #{File.basename(file_data[:name])} - #{file_data[:count].last} #{contributors}}"
|
278
|
+
end
|
279
|
+
end
|
280
|
+
end
|
281
|
+
end
|
166
282
|
steps -= 1
|
167
283
|
|
168
284
|
return unless steps.positive?
|
169
285
|
|
286
|
+
system("git checkout `git rev-list -1 --before='#{(begin_time - duration_in_days).strftime("%B %d %Y")}' HEAD`", [ :out, :err ] => File::NULL)
|
170
287
|
contribution_message(duration_in_days: duration_in_days, directory_path: directory_path,
|
171
288
|
begin_time: begin_time - duration_in_days, steps: steps, debug: debug)
|
172
289
|
end
|
173
290
|
|
174
|
-
|
175
|
-
puts "
|
176
|
-
puts "
|
177
|
-
puts "
|
178
|
-
puts "
|
179
|
-
puts "
|
180
|
-
puts "
|
181
|
-
puts "
|
291
|
+
unless CI
|
292
|
+
puts "\nDirectory: #{REPO_PATH}\n"
|
293
|
+
puts "Time period that data is aggregated by: #{options[:duration_in_days]} days"
|
294
|
+
puts "Steps to jump in the past: #{options[:steps].to_i}"
|
295
|
+
puts "Runs against: #{DEFAULT_BRANCH}"
|
296
|
+
puts "Code extensions: #{CODE_EXTENSIONS}"
|
297
|
+
puts "Regex to detect the teams identifiers: #{TEAM_REGEX}"
|
298
|
+
puts "Excluded contributors: #{EXCLUSIONS}\n" if EXCLUSIONS
|
299
|
+
puts "Excluded file patterns: #{EXCLUDED_FILES.split(',')}\n" if EXCLUDED_FILES
|
300
|
+
puts "Lines of code limit (big files) for the hotspot calculation: #{BIG_FILE_SIZE}"
|
301
|
+
puts "Hotspot detailed output is: #{options[:hotspot_files] ? 'on' : 'off'}\n"
|
302
|
+
puts "CODEOWNERS output is: #{options[:codeowners] ? 'on' : 'off'}\n"
|
303
|
+
puts "Limit of the teams shown in codeownership data: #{TOP_CONTRIBUTED_TEAMS}"
|
304
|
+
puts "Limit of the files shown in codeownership data: #{TOP_TOUCHED_FILES}"
|
305
|
+
puts "CI mode is: #{options[:ci] ? 'on' : 'off'}\n"
|
306
|
+
puts "Debug mode is: #{options[:debug] ? 'on' : 'off'}\n\n"
|
307
|
+
end
|
308
|
+
|
309
|
+
system("git checkout #{DEFAULT_BRANCH}", [ :out ] => File::NULL)
|
310
|
+
system("git pull", [ :out ] => File::NULL)
|
182
311
|
|
183
312
|
contribution_message(duration_in_days: options[:duration_in_days] || 30, directory_path: REPO_PATH,
|
184
313
|
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.2
|
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
|