bundle_update 0.0.1 → 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 +4 -4
- data/CHANGELOG.md +42 -0
- data/DECISIONS.md +129 -0
- data/Gemfile +13 -0
- data/LICENSE.txt +21 -0
- data/README.md +232 -1
- data/bundle_update.gemspec +45 -0
- data/exe/bundle_update +7 -0
- data/lib/bundle_update/api_client.rb +142 -0
- data/lib/bundle_update/cli.rb +125 -0
- data/lib/bundle_update/config.rb +172 -0
- data/lib/bundle_update/diff.rb +103 -0
- data/lib/bundle_update/local_report.rb +124 -0
- data/lib/bundle_update/plugin.rb +126 -0
- data/lib/bundle_update/report_writer.rb +104 -0
- data/lib/bundle_update/runner.rb +120 -0
- data/lib/bundle_update/snapshot.rb +54 -0
- data/lib/bundle_update/version.rb +5 -0
- data/lib/bundle_update.rb +12 -2
- data/plugins.rb +17 -0
- metadata +35 -9
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "time"
|
|
6
|
+
require "English"
|
|
7
|
+
|
|
8
|
+
module BundleUpdate
|
|
9
|
+
# The `bundle_update` executable. Distinguishes its own subcommands
|
|
10
|
+
# (report/auth/status/version) from arguments meant for `bundle update`,
|
|
11
|
+
# and otherwise forwards everything verbatim and unmodified.
|
|
12
|
+
class CLI
|
|
13
|
+
SUBCOMMANDS = %w[report auth status version].freeze
|
|
14
|
+
|
|
15
|
+
def self.start(argv)
|
|
16
|
+
new(argv).run
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(argv)
|
|
20
|
+
@argv = argv
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run
|
|
24
|
+
if @argv.first == "--"
|
|
25
|
+
run_bundle_update(@argv[1..] || [])
|
|
26
|
+
elsif SUBCOMMANDS.include?(@argv.first)
|
|
27
|
+
run_subcommand(@argv[0], @argv[1..] || [])
|
|
28
|
+
else
|
|
29
|
+
run_bundle_update(@argv)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def run_subcommand(name, rest)
|
|
36
|
+
case name
|
|
37
|
+
when "report" then run_report
|
|
38
|
+
when "auth" then run_auth(rest)
|
|
39
|
+
when "status" then run_status
|
|
40
|
+
when "version" then run_version
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def run_report
|
|
45
|
+
Runner.new(config: Config.new).refetch_latest
|
|
46
|
+
0
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def run_auth(rest)
|
|
50
|
+
key = rest.first
|
|
51
|
+
if key.nil? || key.empty?
|
|
52
|
+
warn "Usage: bundle_update auth KEY"
|
|
53
|
+
return 1
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Config.save_api_key(key)
|
|
57
|
+
puts "API key saved (#{Config.mask(key)})."
|
|
58
|
+
0
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def run_status
|
|
62
|
+
config = Config.new
|
|
63
|
+
puts "bundle_update #{BundleUpdate::VERSION}"
|
|
64
|
+
puts "Project: #{config.project_name}"
|
|
65
|
+
puts "API URL: #{config.api_url}"
|
|
66
|
+
puts "API key: #{config.api_key? ? config.masked_api_key : "not configured"}"
|
|
67
|
+
puts "Disabled: #{config.disabled?}"
|
|
68
|
+
puts "API reachable: #{api_reachable?(config) ? "yes" : "no"}" if config.api_key?
|
|
69
|
+
0
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def run_version
|
|
73
|
+
puts "bundle_update #{BundleUpdate::VERSION}"
|
|
74
|
+
0
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# A bare connectivity check against the configured host — no report
|
|
78
|
+
# contract involved, so it can't be affected by API changes.
|
|
79
|
+
def api_reachable?(config)
|
|
80
|
+
uri = URI.parse(config.api_url)
|
|
81
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
82
|
+
http.use_ssl = uri.scheme == "https"
|
|
83
|
+
http.open_timeout = 2
|
|
84
|
+
http.read_timeout = 2
|
|
85
|
+
http.start { true }
|
|
86
|
+
rescue StandardError
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def run_bundle_update(bundler_args)
|
|
91
|
+
lockfile_path = File.join(Dir.pwd, "Gemfile.lock")
|
|
92
|
+
lockfile_before = read_lockfile(lockfile_path)
|
|
93
|
+
|
|
94
|
+
run_command("bundle", "update", *bundler_args)
|
|
95
|
+
code = exit_status
|
|
96
|
+
return code unless code.zero?
|
|
97
|
+
|
|
98
|
+
run_reporting_pipeline(lockfile_path, lockfile_before)
|
|
99
|
+
code
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Thin seams around Kernel#system / $? so specs can verify the exact
|
|
103
|
+
# bundler invocation and simulate its exit code without shelling out.
|
|
104
|
+
def run_command(*args)
|
|
105
|
+
system(*args)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def exit_status
|
|
109
|
+
$CHILD_STATUS&.exitstatus || 1
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def run_reporting_pipeline(lockfile_path, lockfile_before)
|
|
113
|
+
lockfile_after = read_lockfile(lockfile_path)
|
|
114
|
+
Runner.new(config: Config.new).report(lockfile_before: lockfile_before, lockfile_after: lockfile_after, ran_at: Time.now)
|
|
115
|
+
rescue StandardError
|
|
116
|
+
# The reporting pipeline already fails open internally; this is an
|
|
117
|
+
# unreachable last resort so a bug here can never fail the bundle run.
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def read_lockfile(path)
|
|
122
|
+
File.exist?(path) ? File.read(path) : ""
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module BundleUpdate
|
|
7
|
+
# Layered configuration: built-in defaults -> ~/.config/bundle_update/config.yml
|
|
8
|
+
# -> ./.bundle_update.yml (per-project) -> environment variables. Each layer
|
|
9
|
+
# overrides the previous one, key by key.
|
|
10
|
+
#
|
|
11
|
+
# API keys belong in a config file or an environment variable — never in the
|
|
12
|
+
# Gemfile, where they'd be committed to source control.
|
|
13
|
+
class Config
|
|
14
|
+
# TEMPORARY: pointed at the local dev backend (served via puma-dev, see
|
|
15
|
+
# ~/.puma-dev/bundleupdate) while bundleupdate.com isn't live yet.
|
|
16
|
+
# Revert to "https://bundleupdate.com" before shipping.
|
|
17
|
+
DEFAULT_API_URL = "http://bundleupdate.test"
|
|
18
|
+
DEFAULT_REPORTS_DIR = "bundle_update_reports"
|
|
19
|
+
DEFAULT_STATE_DIR = ".bundle_update"
|
|
20
|
+
|
|
21
|
+
ENV_KEYS = {
|
|
22
|
+
"BUNDLE_UPDATE_API_KEY" => :api_key,
|
|
23
|
+
"BUNDLE_UPDATE_API_URL" => :api_url,
|
|
24
|
+
"BUNDLE_UPDATE_DISABLED" => :disabled,
|
|
25
|
+
"BUNDLE_UPDATE_DEBUG" => :debug
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
TRUTHY = %w[1 true yes on].freeze
|
|
29
|
+
|
|
30
|
+
attr_reader :api_key, :api_url, :project_name, :git_remote_url, :reports_dir, :state_dir, :project_root
|
|
31
|
+
|
|
32
|
+
def self.user_config_path
|
|
33
|
+
File.join(Dir.home, ".config", "bundle_update", "config.yml")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.project_config_path(root)
|
|
37
|
+
File.join(root, ".bundle_update.yml")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.mask(key)
|
|
41
|
+
return nil if key.nil? || key.empty?
|
|
42
|
+
|
|
43
|
+
visible = key[0, [key.length - 4, 12].min.clamp(0, key.length)]
|
|
44
|
+
visible.empty? ? "…" : "#{visible}…"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def initialize(env: ENV, project_root: Dir.pwd, user_config_path: self.class.user_config_path)
|
|
48
|
+
@project_root = project_root
|
|
49
|
+
merged = [
|
|
50
|
+
defaults(project_root),
|
|
51
|
+
load_yaml(user_config_path),
|
|
52
|
+
load_yaml(self.class.project_config_path(project_root)),
|
|
53
|
+
from_env(env)
|
|
54
|
+
].each_with_object({}) { |layer, acc| acc.merge!(layer) }
|
|
55
|
+
|
|
56
|
+
@api_key = merged[:api_key]
|
|
57
|
+
@api_url = merged[:api_url]
|
|
58
|
+
@project_name = merged[:project_name]
|
|
59
|
+
@git_remote_url = merged[:git_remote_url]
|
|
60
|
+
@reports_dir = merged[:reports_dir]
|
|
61
|
+
@state_dir = merged[:state_dir]
|
|
62
|
+
@disabled = truthy?(merged[:disabled])
|
|
63
|
+
@debug = truthy?(merged[:debug])
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def api_key?
|
|
67
|
+
!api_key.to_s.empty?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def masked_api_key
|
|
71
|
+
self.class.mask(api_key)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def disabled?
|
|
75
|
+
@disabled
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def debug?
|
|
79
|
+
@debug
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def state_file_path
|
|
83
|
+
File.join(@project_root, state_dir, "last_run.json")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def debug_log_path
|
|
87
|
+
File.join(@project_root, state_dir, "debug.log")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Persists the API key to the user-level config file so it applies across
|
|
91
|
+
# every project. Preserves any other keys already present in that file.
|
|
92
|
+
def self.save_api_key(key, path: user_config_path)
|
|
93
|
+
existing = File.exist?(path) ? (YAML.safe_load_file(path) || {}) : {}
|
|
94
|
+
existing["api_key"] = key
|
|
95
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
96
|
+
File.write(path, existing.to_yaml)
|
|
97
|
+
FileUtils.chmod(0o600, path)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def defaults(project_root)
|
|
103
|
+
{
|
|
104
|
+
api_key: nil,
|
|
105
|
+
api_url: DEFAULT_API_URL,
|
|
106
|
+
project_name: default_project_name(project_root),
|
|
107
|
+
git_remote_url: default_git_remote_url(project_root),
|
|
108
|
+
reports_dir: DEFAULT_REPORTS_DIR,
|
|
109
|
+
state_dir: DEFAULT_STATE_DIR,
|
|
110
|
+
disabled: false,
|
|
111
|
+
debug: false
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def default_project_name(root)
|
|
116
|
+
remote = raw_git_remote(root)
|
|
117
|
+
remote ? remote.split("/").last.to_s.sub(/\.git\z/, "") : File.basename(File.expand_path(root))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def default_git_remote_url(root)
|
|
121
|
+
normalize_remote_url(raw_git_remote(root))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def raw_git_remote(root)
|
|
125
|
+
config_file = File.join(root, ".git", "config")
|
|
126
|
+
return nil unless File.exist?(config_file)
|
|
127
|
+
|
|
128
|
+
content = File.read(config_file)
|
|
129
|
+
match = content.match(/url\s*=\s*(\S+)/)
|
|
130
|
+
match && match[1]
|
|
131
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Collapses SSH (git@host:path), ssh:// and https:// forms of the same
|
|
136
|
+
# remote into one comparable "host/path" identity, so the same repo
|
|
137
|
+
# cloned differently in different places still resolves to the same
|
|
138
|
+
# bundleupdate.com project.
|
|
139
|
+
def normalize_remote_url(url)
|
|
140
|
+
return nil if url.nil?
|
|
141
|
+
|
|
142
|
+
stripped = url.strip.sub(/\.git\z/, "").sub(%r{/\z}, "")
|
|
143
|
+
match = stripped.match(/\Agit@([^:]+):(.+)\z/) || stripped.match(%r{\A\w+://(?:[^@/]+@)?([^/]+)/(.+)\z})
|
|
144
|
+
match ? "#{match[1].downcase}/#{match[2]}" : stripped
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def load_yaml(path)
|
|
148
|
+
return {} unless path && File.exist?(path)
|
|
149
|
+
|
|
150
|
+
data = YAML.safe_load_file(path) || {}
|
|
151
|
+
symbolize(data)
|
|
152
|
+
rescue Psych::SyntaxError
|
|
153
|
+
{}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def from_env(env)
|
|
157
|
+
ENV_KEYS.each_with_object({}) do |(env_key, config_key), hash|
|
|
158
|
+
hash[config_key] = env[env_key] if env.key?(env_key) && !env[env_key].nil?
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def symbolize(hash)
|
|
163
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def truthy?(value)
|
|
167
|
+
return value if [true, false].include?(value)
|
|
168
|
+
|
|
169
|
+
TRUTHY.include?(value.to_s.strip.downcase)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BundleUpdate
|
|
4
|
+
# Computes the set of gem changes between two Snapshots.
|
|
5
|
+
class Diff
|
|
6
|
+
Update = Struct.new(:name, :from, :to, :severity, keyword_init: true)
|
|
7
|
+
|
|
8
|
+
SEVERITIES = %i[major minor patch other].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :updated, :added, :removed
|
|
11
|
+
|
|
12
|
+
def initialize(before, after)
|
|
13
|
+
@before = before
|
|
14
|
+
@after = after
|
|
15
|
+
@updated = []
|
|
16
|
+
@added = []
|
|
17
|
+
@removed = []
|
|
18
|
+
compute
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def empty?
|
|
22
|
+
updated.empty? && added.empty? && removed.empty?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def counts_by_severity
|
|
26
|
+
SEVERITIES.to_h { |s| [s, 0] }.tap do |counts|
|
|
27
|
+
updated.each { |u| counts[u.severity] += 1 }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def total_changes
|
|
32
|
+
updated.size + added.size + removed.size
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def compute
|
|
38
|
+
before_specs = @before.specs
|
|
39
|
+
after_specs = @after.specs
|
|
40
|
+
|
|
41
|
+
after_specs.each do |name, after_spec|
|
|
42
|
+
before_spec = before_specs[name]
|
|
43
|
+
if before_spec.nil?
|
|
44
|
+
@added << after_spec
|
|
45
|
+
elsif before_spec.version != after_spec.version
|
|
46
|
+
@updated << Update.new(
|
|
47
|
+
name: name,
|
|
48
|
+
from: before_spec.version,
|
|
49
|
+
to: after_spec.version,
|
|
50
|
+
severity: classify(before_spec.version, after_spec.version)
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
before_specs.each_key do |name|
|
|
56
|
+
@removed << before_specs[name] unless after_specs.key?(name)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
@updated.sort_by!(&:name)
|
|
60
|
+
@added.sort_by!(&:name)
|
|
61
|
+
@removed.sort_by!(&:name)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# :major/:minor/:patch by comparing the first three Gem::Version segments.
|
|
65
|
+
# Prerelease versions and unparseable strings are always :other — segment
|
|
66
|
+
# comparison across a prerelease boundary (1.0.0 -> 1.0.0.rc1) doesn't map
|
|
67
|
+
# cleanly onto semver severity, so we refuse to guess.
|
|
68
|
+
def classify(from, to)
|
|
69
|
+
from_version = safe_version(from)
|
|
70
|
+
to_version = safe_version(to)
|
|
71
|
+
return :other unless comparable?(from_version, to_version)
|
|
72
|
+
|
|
73
|
+
severity_for(padded_segments(from_version), padded_segments(to_version))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def comparable?(from_version, to_version)
|
|
77
|
+
return false if from_version.nil? || to_version.nil?
|
|
78
|
+
|
|
79
|
+
!from_version.prerelease? && !to_version.prerelease?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def severity_for(from_segments, to_segments)
|
|
83
|
+
return :major if from_segments[0] != to_segments[0]
|
|
84
|
+
return :minor if from_segments[1] != to_segments[1]
|
|
85
|
+
return :patch if from_segments[2] != to_segments[2]
|
|
86
|
+
|
|
87
|
+
:other
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def safe_version(str)
|
|
91
|
+
return nil if str.nil?
|
|
92
|
+
|
|
93
|
+
Gem::Version.new(str)
|
|
94
|
+
rescue ArgumentError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def padded_segments(version)
|
|
99
|
+
segments = version.segments
|
|
100
|
+
[segments[0] || 0, segments[1] || 0, segments[2] || 0]
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BundleUpdate
|
|
4
|
+
# Renders the free-tier output for a Diff: a terminal summary and a
|
|
5
|
+
# markdown report. Pure rendering — writing either to disk is ReportWriter's
|
|
6
|
+
# job, so the same markdown can be reused as the fallback body when the API
|
|
7
|
+
# tier degrades to a 202/error response.
|
|
8
|
+
class LocalReport
|
|
9
|
+
SEVERITY_ORDER = %i[major minor patch other].freeze
|
|
10
|
+
SEVERITY_LABELS = { major: "Major", minor: "Minor", patch: "Patch", other: "Other" }.freeze
|
|
11
|
+
SEVERITY_COLORS = { major: 31, minor: 33, patch: 32, other: 36 }.freeze # red/yellow/green/cyan
|
|
12
|
+
BOLD = 1
|
|
13
|
+
|
|
14
|
+
def initialize(diff:, project_name:, ran_at: Time.now, no_color: nil)
|
|
15
|
+
@diff = diff
|
|
16
|
+
@project_name = project_name
|
|
17
|
+
@ran_at = ran_at
|
|
18
|
+
@no_color = no_color.nil? ? !ENV["NO_COLOR"].to_s.empty? : no_color
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def terminal_summary
|
|
22
|
+
return "bundle_update: no changes to Gemfile.lock\n" if @diff.empty?
|
|
23
|
+
|
|
24
|
+
lines = [count_line(colored: true), ""]
|
|
25
|
+
lines.concat(table_lines)
|
|
26
|
+
if @diff.added.any?
|
|
27
|
+
lines << ""
|
|
28
|
+
lines << "Added: #{@diff.added.map(&:name).join(", ")}"
|
|
29
|
+
end
|
|
30
|
+
if @diff.removed.any?
|
|
31
|
+
lines << ""
|
|
32
|
+
lines << "Removed: #{@diff.removed.map(&:name).join(", ")}"
|
|
33
|
+
end
|
|
34
|
+
"#{lines.join("\n")}\n"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def markdown
|
|
38
|
+
<<~MARKDOWN
|
|
39
|
+
# bundle_update report — #{@project_name}
|
|
40
|
+
|
|
41
|
+
_#{@ran_at.strftime("%Y-%m-%d %H:%M %z")}_
|
|
42
|
+
|
|
43
|
+
## Summary
|
|
44
|
+
|
|
45
|
+
#{summary_bullets.join("\n")}
|
|
46
|
+
|
|
47
|
+
## Security
|
|
48
|
+
|
|
49
|
+
_Security advisory checks aren't available in the free tier. [Get a bundleupdate.com API key](https://www.bundleupdate.com) for CVE-aware changelog reports._
|
|
50
|
+
|
|
51
|
+
## Changes
|
|
52
|
+
|
|
53
|
+
#{changes_markdown}
|
|
54
|
+
MARKDOWN
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def summary_bullets
|
|
60
|
+
return ["- No changes."] if @diff.empty?
|
|
61
|
+
|
|
62
|
+
bullets = ["- #{count_line(colored: false)}"]
|
|
63
|
+
bullets << "- #{@diff.added.size} gem#{"s" unless @diff.added.size == 1} added" if @diff.added.any?
|
|
64
|
+
bullets << "- #{@diff.removed.size} gem#{"s" unless @diff.removed.size == 1} removed" if @diff.removed.any?
|
|
65
|
+
bullets
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def changes_markdown
|
|
69
|
+
return "_No changes._" if @diff.empty?
|
|
70
|
+
|
|
71
|
+
sections = []
|
|
72
|
+
SEVERITY_ORDER.each do |severity|
|
|
73
|
+
group = @diff.updated.select { |u| u.severity == severity }
|
|
74
|
+
next if group.empty?
|
|
75
|
+
|
|
76
|
+
sections << update_table(SEVERITY_LABELS[severity], group)
|
|
77
|
+
end
|
|
78
|
+
sections << name_version_table("Added", @diff.added) if @diff.added.any?
|
|
79
|
+
sections << name_version_table("Removed", @diff.removed) if @diff.removed.any?
|
|
80
|
+
sections.join("\n\n")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def update_table(title, group)
|
|
84
|
+
rows = group.map { |u| "| #{u.name} | #{u.from} | #{u.to} |" }
|
|
85
|
+
["### #{title}", "", "| Gem | From | To |", "|---|---|---|", *rows].join("\n")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def name_version_table(title, specs)
|
|
89
|
+
rows = specs.map { |s| "| #{s.name} | #{s.version} |" }
|
|
90
|
+
["### #{title}", "", "| Gem | Version |", "|---|---|", *rows].join("\n")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def count_line(colored:)
|
|
94
|
+
counts = @diff.counts_by_severity
|
|
95
|
+
parts = SEVERITY_ORDER.filter_map { |sev| "#{counts[sev]} #{sev}" if counts[sev].positive? }
|
|
96
|
+
total = @diff.updated.size
|
|
97
|
+
noun = total == 1 ? "update" : "updates"
|
|
98
|
+
text = "#{parts.join(", ")} #{noun}"
|
|
99
|
+
colored ? colorize(text, BOLD) : text
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def table_lines
|
|
103
|
+
lines = []
|
|
104
|
+
name_width = @diff.updated.map { |u| u.name.length }.max.to_i
|
|
105
|
+
|
|
106
|
+
SEVERITY_ORDER.each do |severity|
|
|
107
|
+
group = @diff.updated.select { |u| u.severity == severity }
|
|
108
|
+
next if group.empty?
|
|
109
|
+
|
|
110
|
+
lines << colorize(SEVERITY_LABELS[severity], SEVERITY_COLORS[severity])
|
|
111
|
+
group.each do |u|
|
|
112
|
+
lines << " #{u.name.ljust(name_width)} #{u.from} → #{u.to}"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
lines
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def colorize(text, code)
|
|
119
|
+
return text if @no_color
|
|
120
|
+
|
|
121
|
+
"\e[#{code}m#{text}\e[0m"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module BundleUpdate
|
|
6
|
+
# Wires the reporting pipeline into Bundler's plugin hook system so it
|
|
7
|
+
# fires automatically on `bundle install`/`bundle update` without the CLI
|
|
8
|
+
# wrapper, once the plugin is installed via `bundle plugin install
|
|
9
|
+
# bundle_update`.
|
|
10
|
+
#
|
|
11
|
+
# See DECISIONS.md for the Bundler source investigation this is based on:
|
|
12
|
+
# both bundle install and bundle update route through
|
|
13
|
+
# Bundler::Installer.install, which fires GEM_BEFORE_INSTALL_ALL
|
|
14
|
+
# ("before-install-all") then GEM_AFTER_INSTALL_ALL ("after-install-all")
|
|
15
|
+
# around the same run() call that resolves the definition and (eventually)
|
|
16
|
+
# writes the lockfile — so both hooks fire for a plain `bundle install`
|
|
17
|
+
# too, and the empty-diff case is handled by Runner, not here.
|
|
18
|
+
module Plugin
|
|
19
|
+
BEFORE_EVENT = "before-install-all"
|
|
20
|
+
AFTER_EVENT = "after-install-all"
|
|
21
|
+
|
|
22
|
+
# Registers the hooks. Every failure mode — missing/renamed Bundler
|
|
23
|
+
# plugin API, hook registration errors, anything — is swallowed so a
|
|
24
|
+
# plugin bug can never break the host's bundle install/update. Called
|
|
25
|
+
# from plugins.rb at the gem root, which Bundler loads automatically
|
|
26
|
+
# once the plugin is installed.
|
|
27
|
+
def self.install!
|
|
28
|
+
return false unless hook_api_available?
|
|
29
|
+
|
|
30
|
+
state = RunState.new
|
|
31
|
+
Bundler::Plugin.add_hook(BEFORE_EVENT) { state.capture_before! }
|
|
32
|
+
Bundler::Plugin.add_hook(AFTER_EVENT) { state.capture_after_and_report! }
|
|
33
|
+
# Defensive fallback for Bundler versions/paths where the lockfile
|
|
34
|
+
# isn't rewritten yet when after-install-all fires: re-check once more
|
|
35
|
+
# at process exit. No-ops if capture_after_and_report! already ran.
|
|
36
|
+
at_exit { state.fallback_report! }
|
|
37
|
+
true
|
|
38
|
+
rescue StandardError
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.hook_api_available?
|
|
43
|
+
defined?(Bundler::Plugin) &&
|
|
44
|
+
Bundler::Plugin.respond_to?(:add_hook) &&
|
|
45
|
+
defined?(Bundler::Plugin::Events) &&
|
|
46
|
+
Bundler::Plugin::Events.respond_to?(:defined_event?) &&
|
|
47
|
+
Bundler::Plugin::Events.defined_event?(BEFORE_EVENT) &&
|
|
48
|
+
Bundler::Plugin::Events.defined_event?(AFTER_EVENT)
|
|
49
|
+
rescue StandardError
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Captures the lockfile before/after around the two hooks and calls
|
|
54
|
+
# Runner exactly once. A plain instance (not a singleton) so each
|
|
55
|
+
# install! call — and each spec example — gets an isolated one.
|
|
56
|
+
class RunState
|
|
57
|
+
def initialize(lockfile_path: nil)
|
|
58
|
+
@lockfile_path = lockfile_path || default_lockfile_path
|
|
59
|
+
@before = nil
|
|
60
|
+
@reported = false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def capture_before!(*)
|
|
64
|
+
@before = read_lockfile
|
|
65
|
+
rescue StandardError
|
|
66
|
+
@before = nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def capture_after_and_report!(*)
|
|
70
|
+
return if @reported || @before.nil?
|
|
71
|
+
|
|
72
|
+
report(resolved_lockfile_content)
|
|
73
|
+
rescue StandardError
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Last-resort safety net: if after-install-all already reported, or we
|
|
78
|
+
# never captured a before state, this is a no-op.
|
|
79
|
+
def fallback_report!
|
|
80
|
+
return if @reported || @before.nil?
|
|
81
|
+
|
|
82
|
+
report(read_lockfile)
|
|
83
|
+
rescue StandardError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def report(after_content)
|
|
90
|
+
return if after_content.nil?
|
|
91
|
+
|
|
92
|
+
@reported = true
|
|
93
|
+
Runner.new(config: Config.new).report(lockfile_before: @before, lockfile_after: after_content, ran_at: Time.now)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The in-memory resolved definition reflects the new lockfile even on
|
|
97
|
+
# Bundler versions/paths where the file on disk hasn't been rewritten
|
|
98
|
+
# yet at hook time.
|
|
99
|
+
def resolved_lockfile_content
|
|
100
|
+
if defined?(Bundler) && Bundler.respond_to?(:definition)
|
|
101
|
+
Bundler.definition.to_lock
|
|
102
|
+
else
|
|
103
|
+
read_lockfile
|
|
104
|
+
end
|
|
105
|
+
rescue StandardError
|
|
106
|
+
read_lockfile
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def read_lockfile
|
|
110
|
+
File.exist?(@lockfile_path) ? File.read(@lockfile_path) : nil
|
|
111
|
+
rescue StandardError
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def default_lockfile_path
|
|
116
|
+
if defined?(Bundler) && Bundler.respond_to?(:default_lockfile)
|
|
117
|
+
Bundler.default_lockfile.to_s
|
|
118
|
+
else
|
|
119
|
+
File.join(Dir.pwd, "Gemfile.lock")
|
|
120
|
+
end
|
|
121
|
+
rescue StandardError
|
|
122
|
+
File.join(Dir.pwd, "Gemfile.lock")
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|