git-reviewer 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,141 @@
1
+ require 'open3'
2
+ require_relative 'blame_tree'
3
+ require_relative '../utils/printer'
4
+ require_relative '../utils/checker'
5
+ require_relative '../algorithm/myers'
6
+
7
+ module GitReviewer
8
+ class Builder
9
+ attr_accessor :source_branch # source branch
10
+ attr_accessor :target_branch
11
+ attr_accessor :source_blame # BlameBranch
12
+ attr_accessor :target_blame # BlameBranch
13
+
14
+ attr_accessor :diff_files # Array<DiffFiles>
15
+
16
+ def initialize(source_branch, target_branch)
17
+ @source_branch = source_branch
18
+ @target_branch = target_branch
19
+ end
20
+
21
+ def build
22
+ # 遍历分支改动的每个文件,得到 BlameFile 数组
23
+ files = Checker.diff_files(@source_branch, @target_branch)
24
+ header_length = 0
25
+
26
+ if files == nil || files.count == 0
27
+ Printer.warning "Warning: there are no analyzable differences between the target branch and source branch."
28
+ exit 0
29
+ else
30
+ header = "============ Diff files between source<#{@source_branch}> and target<#{@target_branch}> ============"
31
+ header_length = header.length
32
+ footer = "=" * header_length
33
+
34
+ Printer.verbose_put header
35
+ Printer.verbose_put files
36
+ Printer.verbose_put footer
37
+ Printer.verbose_put "\n"
38
+ end
39
+
40
+ # 构建 source branch 的 BlameBranch
41
+ source_header_title = " Source Blame Files "
42
+ target_header_title = " Target Blame Files "
43
+ source_prefix_length = (header_length - source_header_title.length) / 2
44
+ target_prefix_length = (header_length - target_header_title.length) / 2
45
+ source_header = "=" * source_prefix_length + source_header_title + "=" * (header_length - source_prefix_length - source_header_title.length)
46
+ target_header = "=" * target_prefix_length + target_header_title + "=" * (header_length - target_prefix_length - target_header_title.length)
47
+ footer = "=" * header_length
48
+
49
+ # 打印 source branch Log
50
+ Printer.verbose_put source_header
51
+ files.each do |file_name|
52
+ Printer.verbose_put "#{file_name}"
53
+ end
54
+ Printer.verbose_put footer
55
+ Printer.verbose_put "\n"
56
+
57
+ # 打印 target branch Log
58
+ Printer.verbose_put target_header
59
+ files.each do |file_name|
60
+ Printer.verbose_put "#{file_name}"
61
+ end
62
+ Printer.verbose_put footer
63
+ Printer.verbose_put "\n"
64
+
65
+ # 构建 source branch & target branch
66
+ @source_blame = blame_branch(@source_branch, files)
67
+ @target_blame = blame_branch(@target_branch, files)
68
+
69
+ # 构建 diff_files
70
+ @diff_files = []
71
+ @source_blame.blame_files.each_with_index do |sfile, index|
72
+ tfile = @target_blame.blame_files[index]
73
+ # Diff 时需要交换 tfile 和 sfile
74
+ myers = Myers.new(tfile, sfile)
75
+ @diff_files.append(myers.resolve)
76
+ end
77
+
78
+ # 打印 Code Diff
79
+
80
+ end
81
+
82
+ def blame_branch(branch, files)
83
+ blame_files = []
84
+ files.each do |file_name|
85
+ bf = BlameFile.new("", [], false, false)
86
+
87
+ if Checker.is_file_exist?(branch, file_name) then
88
+ if Checker.is_file_binary?(branch, file_name)
89
+ bf = BlameFile.new(file_name, [], true, true)
90
+ else
91
+ bf = blame_file(branch, file_name)
92
+ end
93
+ else
94
+ bf = BlameFile.new(file_name, [], false, false)
95
+ end
96
+ blame_files.append(bf)
97
+ end
98
+ result = BlameBranch.new(branch, blame_files)
99
+ return result
100
+ end
101
+
102
+ def blame_file(branch, file_name)
103
+ blame_lines = []
104
+ content = Checker.snapshot_of_blame_file(branch, file_name)
105
+ # 遍历文件的每一行,得到 BlameLine 数组
106
+ content.lines do |line|
107
+ blame_lines.append(blame_line(line))
108
+ end
109
+
110
+ result = BlameFile.new(file_name, blame_lines, true, false)
111
+ return result
112
+ end
113
+
114
+ def blame_line(text)
115
+ # 获取哈希值
116
+ hash = text.slice!(0, 40)
117
+ rest = text.strip
118
+ # 移除 (
119
+ rest = rest[1..-1]
120
+ # 根据时间格式进行拆分
121
+ pattern = /\b\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{4}\b/
122
+ pattern_index = rest.index(pattern)
123
+ pattern_index = pattern_index + 25 # 2022-04-08 06:19:37 -0200 长度为 25
124
+ # 根据特定位置进行拆分
125
+ user_date = rest.slice(0, pattern_index)
126
+ rest = rest.slice(pattern_index , rest.length)
127
+ # user_date, rest = rest.split("+", 2)
128
+ user_date = user_date.strip
129
+ # 提取作者,日期。日期:提取倒数 19 个字符
130
+ date = user_date.slice!(-25, 25)
131
+ date = date.strip
132
+ user = user_date.strip
133
+ # # 提取行号,代码
134
+ line, code = rest.split(")", 2)
135
+ line = line.strip
136
+ # 结果
137
+ result = BlameLine.new(hash, user, date, line, code)
138
+ return result
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,154 @@
1
+ require_relative 'blame_tree'
2
+ require_relative '../utils/printer'
3
+ require 'terminal-table'
4
+
5
+ module GitReviewer
6
+
7
+ class DiffFile
8
+ UNKNOWN = 0 # 未知情况,理论上不存在
9
+ DELETE = 1 # 删除文件
10
+ ADD = 2 # 新增文件
11
+ MODIFY = 3 # 修改文件
12
+
13
+ attr_accessor :operation
14
+ attr_accessor :file_name
15
+ attr_accessor :diff_lines
16
+
17
+ attr_writer :binary
18
+
19
+ def binary?
20
+ @binary
21
+ end
22
+
23
+ def initialize(file_name, diff_lines, operation, binary)
24
+ @file_name = file_name
25
+ @diff_lines = diff_lines
26
+ @operation = operation
27
+ @binary = binary
28
+ end
29
+
30
+ def print_meta_info
31
+ rows = [
32
+ ["filename", file_name],
33
+ ["operation", format_operation],
34
+ ["binary", binary?]
35
+ ]
36
+ table = Terminal::Table.new do |t|
37
+ t.rows = rows
38
+ end
39
+ Printer.verbose_put table
40
+ end
41
+
42
+ def format_property
43
+ result = ""
44
+ case @operation
45
+ when DiffFile::UNKNOWN
46
+ result += "operation: UNKNOWN \n"
47
+ when DiffFile::DELETE
48
+ result += "operation: DELETE \n"
49
+ when DiffFile::ADD
50
+ result += "operation: ADD \n"
51
+ when DiffFile::MODIFY
52
+ result += "operation: MODIFY \n"
53
+ end
54
+
55
+ if binary?
56
+ result += "binary: true \n"
57
+ else
58
+ result += "binary: false \n"
59
+ end
60
+ return result
61
+ end
62
+
63
+ def format_operation
64
+ case @operation
65
+ when DiffFile::UNKNOWN
66
+ return "UNKNOWN"
67
+ when DiffFile::DELETE
68
+ return "DELETE"
69
+ when DiffFile::ADD
70
+ return "ADD"
71
+ when DiffFile::MODIFY
72
+ return "MODIFY"
73
+ end
74
+ end
75
+
76
+ def format_file_name
77
+ return "filename: #{file_name}\n"
78
+ end
79
+
80
+ def format_line_diff
81
+ name_max_length = 0
82
+ result = []
83
+
84
+ diff_lines.each_with_index do |line, index|
85
+ name_max_length = [name_max_length, line.format_user.length].max
86
+
87
+ if line.is_unchange
88
+ if result.size == 0 || !result.last.is_unchange
89
+ result.append(line)
90
+ end
91
+ else
92
+ result.append(line)
93
+ end
94
+ end
95
+
96
+ name_max_length += 2
97
+
98
+ format_content = ""
99
+ result.each do |line|
100
+ if line.operation == DiffLine::DELETE
101
+ format_content += "\033[0;31m#{line.source_line.user.rjust(name_max_length)} #{line.source_line.format_line} - #{line.source_line.description}\033[0m\n"
102
+ elsif line.operation == DiffLine::ADD
103
+ format_content += "\033[0;32m#{line.target_line.user.rjust(name_max_length)} #{line.target_line.format_line} + #{line.target_line.description}\033[0m\n"
104
+ else
105
+ format_content += "...\n"
106
+ end
107
+ end
108
+
109
+ return format_content
110
+ end
111
+ end
112
+
113
+ class DiffLine
114
+ UNCHANGE = 0 # 未变化
115
+ DELETE = 1 # 删除行
116
+ ADD = 2 # 新增行
117
+
118
+ # 对于 UNCHANGE 操作,sLine 有值,tLine 有值
119
+ # 对于 DELETE 操作,sLine 有值,tLine 无值
120
+ # 对于 ADD 操作,sLine 无值,tLine 有值
121
+
122
+ attr_accessor :source_line # 原始行, BlameLine
123
+ attr_accessor :target_line # 目标行, BlameLine
124
+ attr_accessor :operation
125
+
126
+ def initialize(source_line, target_line, operation)
127
+ @source_line = source_line
128
+ @target_line = target_line
129
+ @operation = operation
130
+ end
131
+
132
+ def is_unchange
133
+ operation == UNCHANGE
134
+ end
135
+
136
+ def s_line_number
137
+ source_line.line
138
+ end
139
+
140
+ def t_line_number
141
+ target_line.line
142
+ end
143
+
144
+ def format_user
145
+ if operation == DiffLine::DELETE
146
+ return source_line.user
147
+ elsif operation == DiffLine::ADD
148
+ return target_line.user
149
+ else
150
+ return source_line.user
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,31 @@
1
+ require 'set'
2
+
3
+ module GitReviewer
4
+ class ResultItem
5
+ attr_accessor :name
6
+ attr_accessor :file_count
7
+ attr_accessor :line_count
8
+
9
+ attr_accessor :file_names
10
+
11
+ def initialize(name)
12
+ @name = name
13
+ @file_count = 0
14
+ @line_count = 0
15
+ @file_names = Set.new
16
+ end
17
+
18
+ def add_file_name(name)
19
+ @file_names.add(name)
20
+ @file_count = file_names.count
21
+ end
22
+
23
+ def add_file_count(count)
24
+ @ile_count += count
25
+ end
26
+
27
+ def add_line_count(count)
28
+ @line_count += count
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,112 @@
1
+ require 'claide'
2
+ require_relative './analyze/blame_tree'
3
+ require_relative './analyze/builder'
4
+ require_relative './analyze/analyzer'
5
+ require_relative './algorithm/myers'
6
+ require_relative './option/init_option'
7
+ require_relative './utils/checker'
8
+ require_relative './option/analyze_option'
9
+
10
+ module GitReviewer
11
+
12
+ class Command < CLAide::Command
13
+
14
+ self.abstract_command = false
15
+
16
+ self.description = <<-DESC
17
+ git-reviewer is a git plugin used to analyze who should review a Merge Request or Pull Request, and more details related to code modifications.
18
+ DESC
19
+
20
+ self.command = 'git-reviewer'
21
+
22
+ def self.options
23
+ [
24
+ ['--init', 'Initialize the code review configuration file of the Git repository. It will generate a `gitreviewer.yml` file if needed.'],
25
+ ['--target', 'The target branch to be analyzed, which is the same as the target branch selected when creating a Merge Request or Pull Request.'],
26
+ ['--source', 'Optional, if not specified, the default is the current branch pointed to by Git HEAD. The source branch to be analyzed, which is the same as the source branch selected when creating a Merge Request or Pull Request. '],
27
+ ['--author', 'Only analyze relevant authors involved in code changes.'],
28
+ ['--reviewer', 'Only analyze suggested reviewers for code changes.'],
29
+ ].concat(super)
30
+ end
31
+
32
+ def initialize(argv)
33
+ @init = argv.flag?('init', false)
34
+ @target = argv.option('target')
35
+ @source = argv.option('source')
36
+ @analyze_reviewer = argv.flag?('reviewer', false)
37
+ @analyze_author = argv.flag?('author', false)
38
+ @version = argv.flag?('version', false)
39
+ super
40
+ end
41
+
42
+ def run
43
+ # 处理 help 选项
44
+ if @help_arg
45
+ help!
46
+ return
47
+ end
48
+
49
+ # 处理 version 选项
50
+ if @version
51
+ Printer.put "git-reviewer #{GitReviewer::VERSION}"
52
+ return
53
+ end
54
+
55
+ # 处理 init 选项
56
+ if @init
57
+ initOption = InitOption.new
58
+ initOption.execute
59
+ return
60
+ end
61
+
62
+ # 分析
63
+ analyze
64
+ end
65
+
66
+ def analyze
67
+ # 检查环境
68
+ if !Checker.is_git_repository_exist?
69
+ Printer.red "Error: git repository not exist. Please execute the command in the root director of a git repository."
70
+ exit 1
71
+ end
72
+ # 检查参数
73
+ if !@analyze_author && !@analyze_reviewer
74
+ # 如果两个选项均没有,则默认分析作者和审查者
75
+ @analyze_author = true
76
+ @analyze_reviewer = true
77
+ end
78
+ # 设置默认分支
79
+ if @source == nil
80
+ # 默认 source 为当前分支
81
+ @source = Checker.current_git_branch
82
+ end
83
+ if @target == nil
84
+ Printer.red "Error: target branch cannot be nil or empty. Please use `--target=<branch>` to specify the target branch."
85
+ exit 1
86
+ end
87
+
88
+ # 检查分支
89
+ if @source != nil && @target != nil
90
+ # source 分支
91
+ if !Checker.is_git_branch_exist?(@source)
92
+ Printer.red "Error: source branch `#{@source}` not exist."
93
+ exit 1
94
+ end
95
+ # target 分支
96
+ if !Checker.is_git_branch_exist?(@target)
97
+ Printer.red "Error: target branch `#{@target}` not exist."
98
+ exit 1
99
+ end
100
+ # source、target 判重
101
+ if @source == @target
102
+ Printer.red "Error: source branch and target branch should not be the same."
103
+ exit 1
104
+ end
105
+ end
106
+
107
+ # 执行分析
108
+ analyzeOption = AnalyzeOption.new(@source, @target, @analyze_author, @analyze_reviewer, @verbose)
109
+ analyzeOption.execute
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,93 @@
1
+
2
+ module GitReviewer
3
+ class Configuration
4
+ attr_accessor :project_owner # String
5
+ attr_accessor :folder_owner # Array<FolderOwner>
6
+ attr_accessor :file_owner # Array<FileReOwner>
7
+ attr_accessor :ignore_files # Array<String>
8
+ attr_accessor :ignore_folders # Array<String>
9
+
10
+ def initialize(project_owner, folder_owner, file_owner, ignore_files, ignore_folders)
11
+ @project_owner = project_owner
12
+ @folder_owner = folder_owner.map { |hash| FolderOwner.new(hash["path"], hash["owner"]) }
13
+ @file_owner = file_owner.map { |hash| FileOwner.new(hash["path"], hash["owner"]) }
14
+ @ignore_files = ignore_files
15
+ @ignore_folders = ignore_folders
16
+ end
17
+
18
+ def to_hash
19
+ {
20
+ project_owner: @project_owner,
21
+ folder_owner: @folder_owner.map(&:to_hash),
22
+ file_owner: @file_owner.map(&:to_hash),
23
+ ignore_files: @ignore_files,
24
+ ignore_folders: @ignore_folders
25
+ }
26
+ end
27
+
28
+ def is_ignore?(file_name)
29
+ if @ignore_files != nil && @ignore_files.include?(file_name)
30
+ return true
31
+ end
32
+ if @ignore_folders != nil && @ignore_folders.any?{ |folder| !folder.empty? && file_name.start_with?(folder) }
33
+ return true
34
+ end
35
+ return false
36
+ end
37
+
38
+ def reviewer_of_file(file_name)
39
+ if is_ignore?(file_name)
40
+ return nil
41
+ end
42
+
43
+ fowner = @file_owner.select { |owner| !owner.path.empty? && owner.path == file_name }.first
44
+ if fowner != nil && fowner.owner != nil
45
+ return fowner.owner
46
+ end
47
+
48
+ downer = @folder_owner.select { |owner| !owner.path.empty? && file_name.start_with?(owner.path) }.first
49
+ if downer != nil && downer.owner != nil
50
+ return downer.owner
51
+ end
52
+
53
+ if @project_owner == nil || @project_owner.empty?
54
+ return "<project owner>"
55
+ end
56
+ return @project_owner
57
+ end
58
+ end
59
+
60
+ class FileOwner
61
+ attr_accessor :path
62
+ attr_accessor :owner
63
+
64
+ def initialize(path, owner)
65
+ @path = path
66
+ @owner = owner
67
+ end
68
+
69
+ def to_hash
70
+ {
71
+ path: @path,
72
+ owner: @owner
73
+ }
74
+ end
75
+ end
76
+
77
+ class FolderOwner
78
+ attr_accessor :path
79
+ attr_accessor :owner
80
+
81
+ def initialize(path, owner)
82
+ @path = path
83
+ @owner = owner
84
+ end
85
+
86
+ def to_hash
87
+ {
88
+ path: @path,
89
+ owner: @owner
90
+ }
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,85 @@
1
+ require_relative '../analyze/analyzer'
2
+ require 'terminal-table'
3
+
4
+ module GitReviewer
5
+
6
+ class AnalyzeOption
7
+ attr_accessor :source
8
+ attr_accessor :target
9
+ attr_accessor :analyze_author
10
+ attr_accessor :analyze_reviewer
11
+
12
+
13
+ attr_accessor :analyzer
14
+
15
+ def initialize(source, target, analyze_author, analyze_reviewer, verbose)
16
+ @source = source
17
+ @target = target
18
+ @analyze_author = analyze_author
19
+ @analyze_reviewer = analyze_reviewer
20
+ @analyzer = Analyzer.new(source, target)
21
+ Printer.verbose = verbose
22
+ end
23
+
24
+ def execute
25
+ @analyzer.execute
26
+
27
+ if analyze_author
28
+ show_analyze_author
29
+ end
30
+
31
+ if analyze_reviewer
32
+ show_analyze_reviewer
33
+ end
34
+ end
35
+
36
+ def show_analyze_author
37
+ results = @analyzer.author_results.values
38
+ if results.size <= 0
39
+ return
40
+ end
41
+ results = results.sort_by { |item| item.line_count }.reverse
42
+ total_file = results.sum { |item| item.file_count }
43
+ total_line = results.sum { |item| item.line_count }
44
+ output_rows = []
45
+ results.each do |item|
46
+ file_ratio = item.file_count.to_f / total_file.to_f * 100
47
+ line_ratio = item.line_count.to_f / total_line.to_f * 100
48
+ output_rows << [item.name, item.file_count, "#{file_ratio.round(2)}%", item.line_count, "#{line_ratio.round(2)}%"]
49
+ end
50
+
51
+ table = Terminal::Table.new do |t|
52
+ t.title = "Relevant authors involved in code changes"
53
+ t.headings = ["Related Author", "File Count", "File Ratio", "Line Count", "Line Ratio"]
54
+ t.rows = output_rows
55
+ end
56
+ puts table
57
+ puts "\n"
58
+ end
59
+
60
+ def show_analyze_reviewer
61
+ results = @analyzer.reviewer_results.values
62
+ if results.size <= 0
63
+ return
64
+ end
65
+ results = results.sort_by { |item| item.line_count }.reverse
66
+ total_file = results.sum { |item| item.file_count }
67
+ total_line = results.sum { |item| item.line_count }
68
+ output_rows = []
69
+ results.each do |item|
70
+ file_ratio = item.file_count.to_f / total_file.to_f * 100
71
+ line_ratio = item.line_count.to_f / total_line.to_f * 100
72
+ output_rows << [item.name, item.file_count, "#{file_ratio.round(2)}%", item.line_count, "#{line_ratio.round(2)}%"]
73
+ end
74
+
75
+ table = Terminal::Table.new do |t|
76
+ t.title = "Suggested reviewers for code changes."
77
+ t.headings = ["Suggested Reviewer", "File Count", "File Ratio", "Line Count", "Line Ratio"]
78
+ t.rows = output_rows
79
+ end
80
+
81
+ puts table
82
+ puts "\n"
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,57 @@
1
+ require_relative '../config/configuration'
2
+ require_relative '../utils/printer'
3
+ require 'yaml'
4
+
5
+
6
+ module GitReviewer
7
+
8
+ class InitOption
9
+ attr_accessor :fileExist
10
+
11
+ def execute
12
+ # 判断当前 .gitreviewer 文件是否存在
13
+ check_file_exist
14
+
15
+ # 如果不存在,则创建 .gitreviewer.yml
16
+ if !@fileExist
17
+ create_default_file
18
+ end
19
+ end
20
+
21
+ def check_file_exist
22
+ file_name = ".gitreviewer.yml"
23
+ @fileExist = File.exist?(file_name)
24
+ if @fileExist
25
+ Printer.yellow "`.gitreviewer.yml` already exists. Please do not init again."
26
+ exit 1
27
+ end
28
+ end
29
+
30
+ def create_default_file()
31
+ project_owner = "<project owner>"
32
+ folder_owner = FolderOwner.new("", "")
33
+ file_owner = FileOwner.new("", "")
34
+ config = Configuration.new(project_owner, [folder_owner], [file_owner], [""], [""])
35
+ data = config.to_hash
36
+ data = deep_transform_keys_to_strings(data)
37
+ yaml = YAML.dump(data)
38
+ head = "# `.gitreviewer.yml` is used for a git plugin: git-reviewer.\n# For detailed information about git-reviewer, please refer to https://github.com/baochuquan/git-reviewer\n"
39
+ content = head + yaml
40
+ File.open('.gitreviewer.yml', 'w') do |file|
41
+ file.write(content)
42
+ end
43
+ Printer.put "`.gitreviewer.yml` has been created. If you want to customize settings, please edit this file.\n"
44
+ end
45
+
46
+ def deep_transform_keys_to_strings(value)
47
+ case value
48
+ when Hash
49
+ value.transform_keys(&:to_s).transform_values { |v| deep_transform_keys_to_strings(v) }
50
+ when Array
51
+ value.map { |v| deep_transform_keys_to_strings(v) }
52
+ else
53
+ value # 如果既不是哈希也不是数组,直接返回原值
54
+ end
55
+ end
56
+ end
57
+ end