gemxray 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +151 -0
- data/Rakefile +8 -0
- data/data/rails_changes.yml +6 -0
- data/exe/gemxray +6 -0
- data/lib/gemxray/analyzers/base.rb +59 -0
- data/lib/gemxray/analyzers/redundant_analyzer.rb +72 -0
- data/lib/gemxray/analyzers/unused_analyzer.rb +45 -0
- data/lib/gemxray/analyzers/version_analyzer.rb +44 -0
- data/lib/gemxray/cli.rb +229 -0
- data/lib/gemxray/code_scanner.rb +143 -0
- data/lib/gemxray/config.rb +215 -0
- data/lib/gemxray/dependency_resolver.rb +48 -0
- data/lib/gemxray/editors/gemfile_editor.rb +99 -0
- data/lib/gemxray/editors/github_api_client.rb +56 -0
- data/lib/gemxray/editors/github_pr.rb +222 -0
- data/lib/gemxray/formatters/json.rb +13 -0
- data/lib/gemxray/formatters/terminal.rb +32 -0
- data/lib/gemxray/formatters/yaml.rb +13 -0
- data/lib/gemxray/gem_entry.rb +64 -0
- data/lib/gemxray/gem_metadata_resolver.rb +179 -0
- data/lib/gemxray/gemfile_parser.rb +133 -0
- data/lib/gemxray/gemfile_source_parser.rb +151 -0
- data/lib/gemxray/rails_knowledge.rb +42 -0
- data/lib/gemxray/report.rb +35 -0
- data/lib/gemxray/result.rb +86 -0
- data/lib/gemxray/scanner.rb +74 -0
- data/lib/gemxray/stdgems_client.rb +175 -0
- data/lib/gemxray/version.rb +5 -0
- data/lib/gemxray.rb +36 -0
- metadata +76 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
require "find"
|
|
5
|
+
require "set"
|
|
6
|
+
|
|
7
|
+
module GemXray
|
|
8
|
+
class CodeScanner
|
|
9
|
+
SCAN_EXTENSIONS = %w[.rb .erb .haml .slim .rake .thor .gemspec .ru].freeze
|
|
10
|
+
REQUIRE_PATTERN = /
|
|
11
|
+
(?:
|
|
12
|
+
\brequire(?:_relative)?\s*\(?\s*["']([^"']+)["']
|
|
13
|
+
|
|
|
14
|
+
send\(\s*:require\s*,\s*["']([^"']+)["']
|
|
15
|
+
)
|
|
16
|
+
/x.freeze
|
|
17
|
+
CONSTANT_PATTERN = /\b(?:[A-Z][A-Za-z0-9]*)(?:::[A-Z][A-Za-z0-9]*)*\b/.freeze
|
|
18
|
+
GEMSPEC_DEPENDENCY_PATTERN = /\badd_(?:runtime_)?dependency\s+["']([^"']+)["']/.freeze
|
|
19
|
+
|
|
20
|
+
class Snapshot
|
|
21
|
+
attr_reader :requires, :constants, :dependency_names, :files
|
|
22
|
+
|
|
23
|
+
def initialize(requires:, constants:, dependency_names:, files:)
|
|
24
|
+
@requires = requires
|
|
25
|
+
@constants = constants
|
|
26
|
+
@dependency_names = dependency_names
|
|
27
|
+
@files = files
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def require_used?(candidates)
|
|
31
|
+
Array(candidates).any? do |candidate|
|
|
32
|
+
requires.any? { |reference| reference == candidate || reference.start_with?("#{candidate}/") }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def constant_used?(candidates)
|
|
37
|
+
!(constants & candidates.to_set).empty?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def dependency_used?(gem_name)
|
|
41
|
+
dependency_names.include?(gem_name)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(config)
|
|
46
|
+
@config = config
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def scan
|
|
50
|
+
requires = Set.new
|
|
51
|
+
constants = Set.new
|
|
52
|
+
dependency_names = Set.new
|
|
53
|
+
files = scan_files
|
|
54
|
+
|
|
55
|
+
scan_payloads(files).each do |payload|
|
|
56
|
+
payload[:requires].each { |value| requires << value }
|
|
57
|
+
payload[:constants].each { |value| constants << value }
|
|
58
|
+
payload[:dependency_names].each { |value| dependency_names << value }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
Snapshot.new(
|
|
62
|
+
requires: requires,
|
|
63
|
+
constants: constants,
|
|
64
|
+
dependency_names: dependency_names,
|
|
65
|
+
files: files
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
attr_reader :config
|
|
72
|
+
|
|
73
|
+
def scan_payloads(files)
|
|
74
|
+
count = [files.length, worker_count].min
|
|
75
|
+
return files.filter_map { |path| scan_file(path) } if count <= 1
|
|
76
|
+
|
|
77
|
+
queue = Queue.new
|
|
78
|
+
files.each { |path| queue << path }
|
|
79
|
+
count.times { queue << nil }
|
|
80
|
+
|
|
81
|
+
Array.new(count) do
|
|
82
|
+
Thread.new do
|
|
83
|
+
payloads = []
|
|
84
|
+
|
|
85
|
+
while (path = queue.pop)
|
|
86
|
+
payload = scan_file(path)
|
|
87
|
+
payloads << payload if payload
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
payloads
|
|
91
|
+
end
|
|
92
|
+
end.flat_map(&:value)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def scan_files
|
|
96
|
+
root = config.project_root
|
|
97
|
+
paths = []
|
|
98
|
+
|
|
99
|
+
config.scan_dirs.each do |relative_dir|
|
|
100
|
+
absolute_dir = File.join(root, relative_dir)
|
|
101
|
+
next unless Dir.exist?(absolute_dir)
|
|
102
|
+
|
|
103
|
+
Find.find(absolute_dir) do |path|
|
|
104
|
+
next if File.directory?(path)
|
|
105
|
+
next unless SCAN_EXTENSIONS.include?(File.extname(path))
|
|
106
|
+
|
|
107
|
+
paths << path
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
%w[Gemfile Rakefile].each do |filename|
|
|
112
|
+
absolute_path = File.join(root, filename)
|
|
113
|
+
paths << absolute_path if File.exist?(absolute_path)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
Dir.glob(File.join(root, "*.gemspec")).each { |path| paths << path }
|
|
117
|
+
|
|
118
|
+
paths.uniq
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def scan_file(path)
|
|
122
|
+
content = File.read(path, encoding: "utf-8")
|
|
123
|
+
{
|
|
124
|
+
requires: extract_requires(content),
|
|
125
|
+
constants: content.scan(CONSTANT_PATTERN),
|
|
126
|
+
dependency_names: content.scan(GEMSPEC_DEPENDENCY_PATTERN).flatten
|
|
127
|
+
}
|
|
128
|
+
rescue ArgumentError, Errno::ENOENT
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def worker_count
|
|
133
|
+
cpu_count = Etc.nprocessors
|
|
134
|
+
[[cpu_count, 2].max, 8].min
|
|
135
|
+
rescue StandardError
|
|
136
|
+
4
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def extract_requires(content)
|
|
140
|
+
content.scan(REQUIRE_PATTERN).map { |left, right| left || right }.compact
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module GemXray
|
|
6
|
+
class Config
|
|
7
|
+
DEFAULT_CONFIG_PATH = ".gemxray.yml"
|
|
8
|
+
DEFAULT_SCAN_DIRS = %w[app lib config db script bin exe spec test tasks].freeze
|
|
9
|
+
DEFAULTS = {
|
|
10
|
+
gemfile_path: "Gemfile",
|
|
11
|
+
format: "terminal",
|
|
12
|
+
only: nil,
|
|
13
|
+
severity: "info",
|
|
14
|
+
auto_fix: false,
|
|
15
|
+
dry_run: false,
|
|
16
|
+
ci: false,
|
|
17
|
+
comment: false,
|
|
18
|
+
bundle_install: false,
|
|
19
|
+
whitelist: [],
|
|
20
|
+
scan_dirs: [],
|
|
21
|
+
overrides: {},
|
|
22
|
+
redundant_depth: 2,
|
|
23
|
+
github: {
|
|
24
|
+
base_branch: "main",
|
|
25
|
+
labels: %w[dependencies cleanup],
|
|
26
|
+
reviewers: [],
|
|
27
|
+
per_gem: false,
|
|
28
|
+
bundle_install: true
|
|
29
|
+
}
|
|
30
|
+
}.freeze
|
|
31
|
+
SEVERITY_ORDER = { danger: 0, warning: 1, info: 2 }.freeze
|
|
32
|
+
TEMPLATE = <<~YAML.freeze
|
|
33
|
+
version: 1
|
|
34
|
+
|
|
35
|
+
whitelist:
|
|
36
|
+
- bootsnap
|
|
37
|
+
- tzinfo-data
|
|
38
|
+
|
|
39
|
+
scan_dirs:
|
|
40
|
+
- engines/billing/app
|
|
41
|
+
- engines/billing/lib
|
|
42
|
+
|
|
43
|
+
overrides:
|
|
44
|
+
puma:
|
|
45
|
+
severity: ignore
|
|
46
|
+
|
|
47
|
+
github:
|
|
48
|
+
base_branch: main
|
|
49
|
+
labels:
|
|
50
|
+
- dependencies
|
|
51
|
+
- cleanup
|
|
52
|
+
reviewers: []
|
|
53
|
+
per_gem: false
|
|
54
|
+
bundle_install: true
|
|
55
|
+
YAML
|
|
56
|
+
|
|
57
|
+
attr_reader :config_path, :gemfile_path, :format, :only, :severity_threshold, :whitelist,
|
|
58
|
+
:scan_dirs, :overrides, :redundant_depth, :github
|
|
59
|
+
|
|
60
|
+
def self.load(options = {})
|
|
61
|
+
raw_options = symbolize_keys(options)
|
|
62
|
+
config_path = raw_options.delete(:config_path) || DEFAULT_CONFIG_PATH
|
|
63
|
+
file_options = load_file_config(config_path)
|
|
64
|
+
merged = deep_merge(DEFAULTS, deep_merge(file_options, raw_options))
|
|
65
|
+
|
|
66
|
+
new(merged, config_path: config_path)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.load_file_config(path)
|
|
70
|
+
return {} unless path && File.exist?(path)
|
|
71
|
+
|
|
72
|
+
symbolize_keys(YAML.safe_load(File.read(path), aliases: true) || {})
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.symbolize_keys(value)
|
|
76
|
+
case value
|
|
77
|
+
when Hash
|
|
78
|
+
value.each_with_object({}) do |(key, nested), result|
|
|
79
|
+
result[key.to_sym] = symbolize_keys(nested)
|
|
80
|
+
end
|
|
81
|
+
when Array
|
|
82
|
+
value.map { |item| symbolize_keys(item) }
|
|
83
|
+
else
|
|
84
|
+
value
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.deep_merge(left, right)
|
|
89
|
+
left.merge(right) do |_key, left_value, right_value|
|
|
90
|
+
if left_value.is_a?(Hash) && right_value.is_a?(Hash)
|
|
91
|
+
deep_merge(left_value, right_value)
|
|
92
|
+
elsif left_value.is_a?(Array) && right_value.is_a?(Array)
|
|
93
|
+
(left_value + right_value).uniq
|
|
94
|
+
else
|
|
95
|
+
right_value
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def initialize(options, config_path:)
|
|
101
|
+
@config_path = config_path
|
|
102
|
+
@gemfile_path = File.expand_path(options.fetch(:gemfile_path))
|
|
103
|
+
@format = options.fetch(:format).to_s
|
|
104
|
+
@only = normalize_only(options[:only])
|
|
105
|
+
@severity_threshold = normalize_severity(options.fetch(:severity))
|
|
106
|
+
@whitelist = Array(options[:whitelist]).map(&:to_s).uniq
|
|
107
|
+
@scan_dirs = (DEFAULT_SCAN_DIRS + Array(options[:scan_dirs]).map(&:to_s)).uniq
|
|
108
|
+
@overrides = options.fetch(:overrides, {})
|
|
109
|
+
@redundant_depth = options.fetch(:redundant_depth).to_i
|
|
110
|
+
@github = options.fetch(:github)
|
|
111
|
+
@auto_fix = truthy?(options[:auto_fix])
|
|
112
|
+
@dry_run = truthy?(options[:dry_run])
|
|
113
|
+
@ci = truthy?(options[:ci])
|
|
114
|
+
@comment = truthy?(options[:comment])
|
|
115
|
+
@bundle_install = truthy?(options[:bundle_install])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def lockfile_path
|
|
119
|
+
"#{gemfile_path}.lock"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def project_root
|
|
123
|
+
File.dirname(gemfile_path)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def auto_fix?
|
|
127
|
+
@auto_fix
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def dry_run?
|
|
131
|
+
@dry_run
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def ci?
|
|
135
|
+
@ci
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def comment?
|
|
139
|
+
@comment
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def bundle_install?
|
|
143
|
+
@bundle_install
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def whitelisted?(gem_name)
|
|
147
|
+
whitelist.include?(gem_name.to_s)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def override_for(gem_name)
|
|
151
|
+
overrides[gem_name.to_sym] || overrides[gem_name.to_s]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def ignore_gem?(gem_name)
|
|
155
|
+
override = override_for(gem_name)
|
|
156
|
+
override && override[:severity].to_s == "ignore"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def override_severity_for(gem_name)
|
|
160
|
+
override = override_for(gem_name)
|
|
161
|
+
severity = override && override[:severity]
|
|
162
|
+
return nil if severity.nil? || severity.to_s == "ignore"
|
|
163
|
+
|
|
164
|
+
normalize_severity(severity)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def severity_in_scope?(severity)
|
|
168
|
+
SEVERITY_ORDER.fetch(severity) <= SEVERITY_ORDER.fetch(severity_threshold)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def github_base_branch
|
|
172
|
+
github.fetch(:base_branch, "main")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def github_labels
|
|
176
|
+
Array(github.fetch(:labels, []))
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def github_reviewers
|
|
180
|
+
Array(github.fetch(:reviewers, []))
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def github_per_gem?
|
|
184
|
+
truthy?(github.fetch(:per_gem, false))
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def github_bundle_install?
|
|
188
|
+
truthy?(github.fetch(:bundle_install, true))
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
private
|
|
192
|
+
|
|
193
|
+
def normalize_only(value)
|
|
194
|
+
items =
|
|
195
|
+
case value
|
|
196
|
+
when nil then nil
|
|
197
|
+
when String then value.split(",")
|
|
198
|
+
else Array(value)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
items&.map { |item| item.to_s.strip }&.reject(&:empty?)&.map(&:to_sym)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def normalize_severity(value)
|
|
205
|
+
key = value.to_s.strip.downcase.to_sym
|
|
206
|
+
return key if SEVERITY_ORDER.key?(key)
|
|
207
|
+
|
|
208
|
+
raise Error, "unknown severity: #{value}"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def truthy?(value)
|
|
212
|
+
value == true || value.to_s == "true"
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module GemXray
|
|
6
|
+
class DependencyResolver
|
|
7
|
+
def initialize(dependency_tree)
|
|
8
|
+
@dependency_tree = dependency_tree
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def find_parent(target:, roots:, max_depth:)
|
|
12
|
+
roots.each do |root|
|
|
13
|
+
next if root == target
|
|
14
|
+
|
|
15
|
+
path = find_path(root, target, max_depth)
|
|
16
|
+
return path if path
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
attr_reader :dependency_tree
|
|
25
|
+
|
|
26
|
+
def find_path(root, target, max_depth)
|
|
27
|
+
visited = Set.new([root])
|
|
28
|
+
queue = [[root, [root], [], 0]]
|
|
29
|
+
|
|
30
|
+
until queue.empty?
|
|
31
|
+
current, path, edges, depth = queue.shift
|
|
32
|
+
next if depth >= max_depth
|
|
33
|
+
|
|
34
|
+
Array(dependency_tree[current]).each do |edge|
|
|
35
|
+
child = edge.name
|
|
36
|
+
next if visited.include?(child) && child != target
|
|
37
|
+
|
|
38
|
+
return { gems: path + [child], edges: edges + [edge] } if child == target
|
|
39
|
+
|
|
40
|
+
visited << child
|
|
41
|
+
queue << [child, path + [child], edges + [edge], depth + 1]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module GemXray
|
|
6
|
+
module Editors
|
|
7
|
+
class GemfileEditor
|
|
8
|
+
EditResult = Struct.new(:removed, :skipped, :dry_run, :backup_path, :preview, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
def initialize(gemfile_path)
|
|
11
|
+
@gemfile_path = File.expand_path(gemfile_path)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def apply(results, dry_run:, comment:, backup: true)
|
|
15
|
+
lines = File.readlines(gemfile_path, chomp: false)
|
|
16
|
+
preview_hunks = []
|
|
17
|
+
removed = []
|
|
18
|
+
skipped = []
|
|
19
|
+
|
|
20
|
+
results.sort_by { |result| -(result.gemfile_line || 0) }.each do |result|
|
|
21
|
+
line_number = result.gemfile_line
|
|
22
|
+
end_line = result.gemfile_end_line || line_number
|
|
23
|
+
if !line_number || !gem_line?(lines[line_number - 1], result.gem_name)
|
|
24
|
+
skipped << result.gem_name
|
|
25
|
+
next
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
replacement =
|
|
29
|
+
if comment
|
|
30
|
+
["#{leading_whitespace(lines[line_number - 1])}# Removed by gemxray: #{comment_summary(result)}\n"]
|
|
31
|
+
else
|
|
32
|
+
[]
|
|
33
|
+
end
|
|
34
|
+
original = lines[(line_number - 1)..(end_line - 1)]
|
|
35
|
+
preview_hunks << build_preview_hunk(result, original, replacement)
|
|
36
|
+
|
|
37
|
+
if comment
|
|
38
|
+
lines[(line_number - 1)..(end_line - 1)] = replacement
|
|
39
|
+
else
|
|
40
|
+
lines[(line_number - 1)..(end_line - 1)] = replacement
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
removed << result.gem_name
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
backup_path = nil
|
|
47
|
+
unless dry_run || removed.empty?
|
|
48
|
+
if backup
|
|
49
|
+
backup_path = "#{gemfile_path}.bak"
|
|
50
|
+
File.write(backup_path, File.read(gemfile_path))
|
|
51
|
+
end
|
|
52
|
+
File.write(gemfile_path, lines.join)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
EditResult.new(
|
|
56
|
+
removed: removed.reverse,
|
|
57
|
+
skipped: skipped.uniq,
|
|
58
|
+
dry_run: dry_run,
|
|
59
|
+
backup_path: backup_path,
|
|
60
|
+
preview: preview_hunks.reverse.join("\n")
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def bundle_install!
|
|
65
|
+
stdout, stderr, status = Open3.capture3("bundle", "install", chdir: project_root)
|
|
66
|
+
return stdout if status.success?
|
|
67
|
+
|
|
68
|
+
raise Error, "bundle install failed: #{stderr.strip.empty? ? stdout.strip : stderr.strip}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
attr_reader :gemfile_path
|
|
74
|
+
|
|
75
|
+
def gem_line?(line, gem_name)
|
|
76
|
+
line && line.match?(/^\s*gem\s+["']#{Regexp.escape(gem_name)}["']/)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def comment_summary(result)
|
|
80
|
+
"#{result.gem_name} - #{result.reasons.map(&:detail).join(' / ')}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_preview_hunk(result, original_lines, replacement_lines)
|
|
84
|
+
header = "@@ #{File.basename(gemfile_path)}:#{result.gemfile_line}-#{result.gemfile_end_line || result.gemfile_line} #{result.gem_name} @@"
|
|
85
|
+
removed = Array(original_lines).map { |line| "-#{line.chomp}" }
|
|
86
|
+
added = Array(replacement_lines).map { |line| "+#{line.chomp}" }
|
|
87
|
+
([header] + removed + added).join("\n")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def leading_whitespace(line)
|
|
91
|
+
line[/\A\s*/] || ""
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def project_root
|
|
95
|
+
File.dirname(gemfile_path)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module GemXray
|
|
8
|
+
module Editors
|
|
9
|
+
class GithubApiClient
|
|
10
|
+
API_BASE = URI("https://api.github.com").freeze
|
|
11
|
+
|
|
12
|
+
def initialize(token:, repository:)
|
|
13
|
+
@token = token
|
|
14
|
+
@repository = repository
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create_pull_request(base:, head:, title:, body:, labels:, reviewers:)
|
|
18
|
+
pr = post_json("/repos/#{repository}/pulls", {
|
|
19
|
+
title: title,
|
|
20
|
+
head: head,
|
|
21
|
+
base: base,
|
|
22
|
+
body: body
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
issue_number = pr.fetch("number")
|
|
26
|
+
post_json("/repos/#{repository}/issues/#{issue_number}/labels", { labels: labels }) unless labels.empty?
|
|
27
|
+
post_json("/repos/#{repository}/pulls/#{issue_number}/requested_reviewers", { reviewers: reviewers }) unless reviewers.empty?
|
|
28
|
+
|
|
29
|
+
pr.fetch("html_url")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
attr_reader :token, :repository
|
|
35
|
+
|
|
36
|
+
def post_json(path, payload)
|
|
37
|
+
uri = API_BASE.dup
|
|
38
|
+
uri.path = path
|
|
39
|
+
|
|
40
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
|
|
41
|
+
request = Net::HTTP::Post.new(uri)
|
|
42
|
+
request["Accept"] = "application/vnd.github+json"
|
|
43
|
+
request["Authorization"] = "Bearer #{token}"
|
|
44
|
+
request["X-GitHub-Api-Version"] = "2022-11-28"
|
|
45
|
+
request["Content-Type"] = "application/json"
|
|
46
|
+
request.body = JSON.dump(payload)
|
|
47
|
+
http.request(request)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
raise Error, "GitHub API request failed: #{response.code} #{response.body}" unless response.is_a?(Net::HTTPSuccess)
|
|
51
|
+
|
|
52
|
+
JSON.parse(response.body)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|